Skip to content

Commit 59ca82b

Browse files
authored
[Accessibility] Introduce activator node refs and automatic focus management (#748)
* Introduce activator node ref for `useDraggable` and `useSortable` Introducing the concept of activator node refs for `useDraggable` allows @dnd-kit to handle common use-cases such as restoring focus on the activator node after dragging via the keyboard or only allowing the activator node to instantiate the keyboard sensor. Consumers of `useDraggable` and `useSortable` may now optionally set the activator node ref on the element that receives listeners It's common for the activator element (the element that receives the sensor listeners) to differ from the draggable node. When this happens, @dnd-kit has no reliable way to get a reference to the activator node after dragging ends, as the original `event.target` that instantiated the sensor may no longer be mounted in the DOM or associated with the draggable node that was previously active. * Automatically restore focus on the first focusable node Focus management is now automatically handled by @dnd-kit. When the activator event is a Keyboard event, @dnd-kit will now attempt to automatically restore focus back to the first focusable node of the activator node or draggable node. If no activator node is specified via the setActivatorNodeRef setter function of useDraggble and useSortable, @dnd-kit will automatically restore focus on the first focusable node of the draggable node set via the setNodeRef setter function of useDraggable and useSortable. If you were previously managing focus manually and would like to opt-out of automatic focus management, use the newly introduced restoreFocus property of the accessibility prop of <DndContext>:
1 parent 4173087 commit 59ca82b

File tree

26 files changed

+288
-77
lines changed

26 files changed

+288
-77
lines changed

.changeset/activator-node-ref.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
'@dnd-kit/core': minor
3+
'@dnd-kit/sortable': minor
4+
---
5+
6+
#### Introducing activator node refs
7+
8+
Introducing the concept of activator node refs for `useDraggable` and `useSortable`. This allows @dnd-kit to handle common use-cases such as restoring focus on the activator node after dragging via the keyboard or only allowing the activator node to instantiate the keyboard sensor.
9+
10+
Consumers of `useDraggable` and `useSortable` may now optionally set the activator node ref on the element that receives listeners:
11+
12+
```diff
13+
import {useDraggable} from '@dnd-kit/core';
14+
15+
function Draggable(props) {
16+
const {
17+
listeners,
18+
setNodeRef,
19+
+ setActivatorNodeRef,
20+
} = useDraggable({id: props.id});
21+
22+
return (
23+
<div ref={setNodeRef}>
24+
Draggable element
25+
<button
26+
{...listeners}
27+
+ ref={setActivatorNodeRef}
28+
>
29+
:: Drag Handle
30+
</button>
31+
</div>
32+
)
33+
}
34+
```
35+
36+
It's common for the activator element (the element that receives the sensor listeners) to differ from the draggable node. When this happens, @dnd-kit has no reliable way to get a reference to the activator node after dragging ends, as the original `event.target` that instantiated the sensor may no longer be mounted in the DOM or associated with the draggable node that was previously active.
37+
38+
#### Automatically restoring focus
39+
40+
Focus management is now automatically handled by @dnd-kit. When the activator event is a Keyboard event, @dnd-kit will now attempt to automatically restore focus back to the first focusable node of the activator node or draggable node.
41+
42+
If no activator node is specified via the `setActivatorNodeRef` setter function of `useDraggble` and `useSortable`, @dnd-kit will automatically restore focus on the first focusable node of the draggable node set via the `setNodeRef` setter function of `useDraggable` and `useSortable`.
43+
44+
If you were previously managing focus manually and would like to opt-out of automatic focus management, use the newly introduced `restoreFocus` property of the `accessibility` prop of `<DndContext>`:
45+
46+
```diff
47+
<DndContext
48+
accessibility={{
49+
+ restoreFocus: false
50+
}}
51+
```

.changeset/first-focusable-node.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@dnd-kit/utilities': minor
3+
---
4+
5+
Introduced the `findFirstFocusableNode` utility function that returns the first focusable node within a given HTMLElement, or the element itself if it is focusable.

packages/core/src/components/Accessibility/Accessibility.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import {createPortal} from 'react-dom';
33
import {useUniqueId} from '@dnd-kit/utilities';
44
import {HiddenText, LiveRegion, useAnnouncement} from '@dnd-kit/accessibility';
55

6-
import type {Announcements, ScreenReaderInstructions} from './types';
6+
import {DndMonitorArguments, useDndMonitor} from '../../hooks/monitor';
77
import type {UniqueIdentifier} from '../../types';
8+
9+
import type {Announcements, ScreenReaderInstructions} from './types';
810
import {
911
defaultAnnouncements,
1012
defaultScreenReaderInstructions,
1113
} from './defaults';
12-
import {DndMonitorArguments, useDndMonitor} from '../../hooks/monitor';
1314

1415
interface Props {
1516
announcements?: Announcements;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {useContext, useEffect} from 'react';
2+
import {
3+
findFirstFocusableNode,
4+
isKeyboardEvent,
5+
usePrevious,
6+
} from '@dnd-kit/utilities';
7+
8+
import {InternalContext} from '../../../store';
9+
10+
interface Props {
11+
disabled: boolean;
12+
}
13+
14+
export function RestoreFocus({disabled}: Props) {
15+
const {active, activatorEvent, draggableNodes} = useContext(InternalContext);
16+
const previousActivatorEvent = usePrevious(activatorEvent);
17+
const previousActiveId = usePrevious(active?.id);
18+
19+
// Restore keyboard focus on the activator node
20+
useEffect(() => {
21+
if (disabled) {
22+
return;
23+
}
24+
25+
if (!activatorEvent && previousActivatorEvent && previousActiveId != null) {
26+
if (!isKeyboardEvent(previousActivatorEvent)) {
27+
return;
28+
}
29+
30+
if (document.activeElement === previousActivatorEvent.target) {
31+
// No need to restore focus
32+
return;
33+
}
34+
35+
const draggableNode = draggableNodes[previousActiveId];
36+
37+
if (!draggableNode) {
38+
return;
39+
}
40+
41+
const {activatorNode, node} = draggableNode;
42+
43+
if (!activatorNode.current && !node.current) {
44+
return;
45+
}
46+
47+
requestAnimationFrame(() => {
48+
for (const element of [activatorNode.current, node.current]) {
49+
if (!element) {
50+
continue;
51+
}
52+
53+
const focusableNode = findFirstFocusableNode(element);
54+
55+
if (focusableNode) {
56+
focusableNode.focus();
57+
break;
58+
}
59+
}
60+
});
61+
}
62+
}, [
63+
activatorEvent,
64+
disabled,
65+
draggableNodes,
66+
previousActiveId,
67+
previousActivatorEvent,
68+
]);
69+
70+
return null;
71+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {RestoreFocus} from './RestoreFocus';

packages/core/src/components/Accessibility/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export {Accessibility} from './Accessibility';
2+
export {RestoreFocus} from './components';
23
export {
34
defaultAnnouncements,
45
defaultScreenReaderInstructions,

packages/core/src/components/DndContext/DndContext.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import type {
5050
Sensor,
5151
SensorContext,
5252
SensorDescriptor,
53-
SensorHandler,
53+
SensorActivatorFunction,
5454
SensorInstance,
5555
} from '../../sensors';
5656
import {
@@ -74,6 +74,7 @@ import type {
7474
import {
7575
Accessibility,
7676
Announcements,
77+
RestoreFocus,
7778
ScreenReaderInstructions,
7879
} from '../Accessibility';
7980

@@ -89,6 +90,7 @@ export interface Props {
8990
accessibility?: {
9091
announcements?: Announcements;
9192
container?: Element;
93+
restoreFocus?: boolean;
9294
screenReaderInstructions?: ScreenReaderInstructions;
9395
};
9496
autoScroll?: boolean | AutoScrollOptions;
@@ -447,23 +449,35 @@ export const DndContext = memo(function DndContext({
447449

448450
const bindActivatorToSensorInstantiator = useCallback(
449451
(
450-
handler: SensorHandler,
452+
handler: SensorActivatorFunction<any>,
451453
sensor: SensorDescriptor<any>
452454
): SyntheticListener['handler'] => {
453455
return (event, active) => {
454456
const nativeEvent = event.nativeEvent as DndEvent;
457+
const activeDraggableNode = draggableNodes[active];
455458

456459
if (
457-
// No active draggable
460+
// Another sensor is already instantiating
458461
activeRef.current !== null ||
462+
// No active draggable
463+
!activeDraggableNode ||
459464
// Event has already been captured
460465
nativeEvent.dndKit ||
461466
nativeEvent.defaultPrevented
462467
) {
463468
return;
464469
}
465470

466-
if (handler(event, sensor.options) === true) {
471+
const activationContext = {
472+
active: activeDraggableNode,
473+
};
474+
const shouldActivate = handler(
475+
event,
476+
sensor.options,
477+
activationContext
478+
);
479+
480+
if (shouldActivate === true) {
467481
nativeEvent.dndKit = {
468482
capturedBy: sensor.sensor,
469483
};
@@ -473,7 +487,7 @@ export const DndContext = memo(function DndContext({
473487
}
474488
};
475489
},
476-
[instantiateSensor]
490+
[draggableNodes, instantiateSensor]
477491
);
478492

479493
const activators = useCombineActivators(
@@ -681,6 +695,7 @@ export const DndContext = memo(function DndContext({
681695
{children}
682696
</ActiveDraggableContext.Provider>
683697
</PublicContext.Provider>
698+
<RestoreFocus disabled={accessibility?.restoreFocus === false} />
684699
</InternalContext.Provider>
685700
<Accessibility
686701
{...accessibility}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export {useMeasuringConfiguration} from './useMeasuringConfiguration';
2+
export {useLayoutShiftScrollCompensation} from './useLayoutShiftScrollCompensation';

packages/core/src/components/DndContext/hooks.ts renamed to packages/core/src/components/DndContext/hooks/useLayoutShiftScrollCompensation.ts

Lines changed: 6 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,11 @@
1-
import {useMemo, useRef} from 'react';
1+
import {useRef} from 'react';
22
import {useIsomorphicLayoutEffect} from '@dnd-kit/utilities';
3-
import type {DeepRequired} from '@dnd-kit/utilities';
43

5-
import {getRectDelta} from '../../utilities/rect';
6-
import {getFirstScrollableAncestor} from '../../utilities/scroll';
7-
import type {ClientRect} from '../../types';
8-
import {defaultMeasuringConfiguration} from './defaults';
9-
import type {MeasuringFunction, MeasuringConfiguration} from './types';
10-
import type {DraggableNode} from '../../store';
11-
12-
export function useMeasuringConfiguration(
13-
config: MeasuringConfiguration | undefined
14-
): DeepRequired<MeasuringConfiguration> {
15-
return useMemo(
16-
() => ({
17-
draggable: {
18-
...defaultMeasuringConfiguration.draggable,
19-
...config?.draggable,
20-
},
21-
droppable: {
22-
...defaultMeasuringConfiguration.droppable,
23-
...config?.droppable,
24-
},
25-
dragOverlay: {
26-
...defaultMeasuringConfiguration.dragOverlay,
27-
...config?.dragOverlay,
28-
},
29-
}),
30-
// eslint-disable-next-line react-hooks/exhaustive-deps
31-
[config?.draggable, config?.droppable, config?.dragOverlay]
32-
);
33-
}
4+
import {getRectDelta} from '../../../utilities/rect';
5+
import {getFirstScrollableAncestor} from '../../../utilities/scroll';
6+
import type {ClientRect} from '../../../types';
7+
import type {DraggableNode} from '../../../store';
8+
import type {MeasuringFunction} from '../types';
349

3510
interface Options {
3611
activeNode: DraggableNode | null | undefined;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import {useMemo} from 'react';
2+
import type {DeepRequired} from '@dnd-kit/utilities';
3+
4+
import {defaultMeasuringConfiguration} from '../defaults';
5+
import type {MeasuringConfiguration} from '../types';
6+
7+
export function useMeasuringConfiguration(
8+
config: MeasuringConfiguration | undefined
9+
): DeepRequired<MeasuringConfiguration> {
10+
return useMemo(
11+
() => ({
12+
draggable: {
13+
...defaultMeasuringConfiguration.draggable,
14+
...config?.draggable,
15+
},
16+
droppable: {
17+
...defaultMeasuringConfiguration.droppable,
18+
...config?.droppable,
19+
},
20+
dragOverlay: {
21+
...defaultMeasuringConfiguration.dragOverlay,
22+
...config?.dragOverlay,
23+
},
24+
}),
25+
// eslint-disable-next-line react-hooks/exhaustive-deps
26+
[config?.draggable, config?.droppable, config?.dragOverlay]
27+
);
28+
}

packages/core/src/hooks/useDraggable.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,13 @@ export function useDraggable({
6161
isDragging ? ActiveDraggableContext : NullContext
6262
);
6363
const [node, setNodeRef] = useNodeRef();
64+
const [activatorNode, setActivatorNodeRef] = useNodeRef();
6465
const listeners = useSyntheticListeners(activators, id);
6566
const dataRef = useLatestValue(data);
6667

6768
useIsomorphicLayoutEffect(
6869
() => {
69-
draggableNodes[id] = {id, key, node, data: dataRef};
70+
draggableNodes[id] = {id, key, node, activatorNode, data: dataRef};
7071

7172
return () => {
7273
const node = draggableNodes[id];
@@ -101,6 +102,7 @@ export function useDraggable({
101102
node,
102103
over,
103104
setNodeRef,
105+
setActivatorNodeRef,
104106
transform,
105107
};
106108
}

packages/core/src/hooks/utilities/useCombineActivators.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {useMemo} from 'react';
22

3-
import type {SensorDescriptor, SensorHandler} from '../../sensors';
3+
import type {SensorActivatorFunction, SensorDescriptor} from '../../sensors';
44
import type {
55
SyntheticListener,
66
SyntheticListeners,
@@ -9,7 +9,7 @@ import type {
99
export function useCombineActivators(
1010
sensors: SensorDescriptor<any>[],
1111
getSyntheticHandler: (
12-
handler: SensorHandler,
12+
handler: SensorActivatorFunction<any>,
1313
sensor: SensorDescriptor<any>
1414
) => SyntheticListener['handler']
1515
): SyntheticListeners {

packages/core/src/sensors/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type {
3232
Response as SensorResponse,
3333
Sensor,
3434
Sensors,
35+
SensorActivatorFunction,
3536
SensorDescriptor,
3637
SensorContext,
3738
SensorHandler,

0 commit comments

Comments
 (0)