Skip to content

Commit

Permalink
[Accessibility] Introduce activator node refs and automatic focus man…
Browse files Browse the repository at this point in the history
…agement (#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>:
  • Loading branch information
clauderic authored May 19, 2022
1 parent 4173087 commit 59ca82b
Show file tree
Hide file tree
Showing 26 changed files with 288 additions and 77 deletions.
51 changes: 51 additions & 0 deletions .changeset/activator-node-ref.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
'@dnd-kit/core': minor
'@dnd-kit/sortable': minor
---

#### Introducing activator node refs

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.

Consumers of `useDraggable` and `useSortable` may now optionally set the activator node ref on the element that receives listeners:

```diff
import {useDraggable} from '@dnd-kit/core';

function Draggable(props) {
const {
listeners,
setNodeRef,
+ setActivatorNodeRef,
} = useDraggable({id: props.id});

return (
<div ref={setNodeRef}>
Draggable element
<button
{...listeners}
+ ref={setActivatorNodeRef}
>
:: Drag Handle
</button>
</div>
)
}
```

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 restoring focus

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>`:

```diff
<DndContext
accessibility={{
+ restoreFocus: false
}}
```
5 changes: 5 additions & 0 deletions .changeset/first-focusable-node.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@dnd-kit/utilities': minor
---

Introduced the `findFirstFocusableNode` utility function that returns the first focusable node within a given HTMLElement, or the element itself if it is focusable.
5 changes: 3 additions & 2 deletions packages/core/src/components/Accessibility/Accessibility.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import {createPortal} from 'react-dom';
import {useUniqueId} from '@dnd-kit/utilities';
import {HiddenText, LiveRegion, useAnnouncement} from '@dnd-kit/accessibility';

import type {Announcements, ScreenReaderInstructions} from './types';
import {DndMonitorArguments, useDndMonitor} from '../../hooks/monitor';
import type {UniqueIdentifier} from '../../types';

import type {Announcements, ScreenReaderInstructions} from './types';
import {
defaultAnnouncements,
defaultScreenReaderInstructions,
} from './defaults';
import {DndMonitorArguments, useDndMonitor} from '../../hooks/monitor';

interface Props {
announcements?: Announcements;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {useContext, useEffect} from 'react';
import {
findFirstFocusableNode,
isKeyboardEvent,
usePrevious,
} from '@dnd-kit/utilities';

import {InternalContext} from '../../../store';

interface Props {
disabled: boolean;
}

export function RestoreFocus({disabled}: Props) {
const {active, activatorEvent, draggableNodes} = useContext(InternalContext);
const previousActivatorEvent = usePrevious(activatorEvent);
const previousActiveId = usePrevious(active?.id);

// Restore keyboard focus on the activator node
useEffect(() => {
if (disabled) {
return;
}

if (!activatorEvent && previousActivatorEvent && previousActiveId != null) {
if (!isKeyboardEvent(previousActivatorEvent)) {
return;
}

if (document.activeElement === previousActivatorEvent.target) {
// No need to restore focus
return;
}

const draggableNode = draggableNodes[previousActiveId];

if (!draggableNode) {
return;
}

const {activatorNode, node} = draggableNode;

if (!activatorNode.current && !node.current) {
return;
}

requestAnimationFrame(() => {
for (const element of [activatorNode.current, node.current]) {
if (!element) {
continue;
}

const focusableNode = findFirstFocusableNode(element);

if (focusableNode) {
focusableNode.focus();
break;
}
}
});
}
}, [
activatorEvent,
disabled,
draggableNodes,
previousActiveId,
previousActivatorEvent,
]);

return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {RestoreFocus} from './RestoreFocus';
1 change: 1 addition & 0 deletions packages/core/src/components/Accessibility/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export {Accessibility} from './Accessibility';
export {RestoreFocus} from './components';
export {
defaultAnnouncements,
defaultScreenReaderInstructions,
Expand Down
25 changes: 20 additions & 5 deletions packages/core/src/components/DndContext/DndContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import type {
Sensor,
SensorContext,
SensorDescriptor,
SensorHandler,
SensorActivatorFunction,
SensorInstance,
} from '../../sensors';
import {
Expand All @@ -74,6 +74,7 @@ import type {
import {
Accessibility,
Announcements,
RestoreFocus,
ScreenReaderInstructions,
} from '../Accessibility';

Expand All @@ -89,6 +90,7 @@ export interface Props {
accessibility?: {
announcements?: Announcements;
container?: Element;
restoreFocus?: boolean;
screenReaderInstructions?: ScreenReaderInstructions;
};
autoScroll?: boolean | AutoScrollOptions;
Expand Down Expand Up @@ -447,23 +449,35 @@ export const DndContext = memo(function DndContext({

const bindActivatorToSensorInstantiator = useCallback(
(
handler: SensorHandler,
handler: SensorActivatorFunction<any>,
sensor: SensorDescriptor<any>
): SyntheticListener['handler'] => {
return (event, active) => {
const nativeEvent = event.nativeEvent as DndEvent;
const activeDraggableNode = draggableNodes[active];

if (
// No active draggable
// Another sensor is already instantiating
activeRef.current !== null ||
// No active draggable
!activeDraggableNode ||
// Event has already been captured
nativeEvent.dndKit ||
nativeEvent.defaultPrevented
) {
return;
}

if (handler(event, sensor.options) === true) {
const activationContext = {
active: activeDraggableNode,
};
const shouldActivate = handler(
event,
sensor.options,
activationContext
);

if (shouldActivate === true) {
nativeEvent.dndKit = {
capturedBy: sensor.sensor,
};
Expand All @@ -473,7 +487,7 @@ export const DndContext = memo(function DndContext({
}
};
},
[instantiateSensor]
[draggableNodes, instantiateSensor]
);

const activators = useCombineActivators(
Expand Down Expand Up @@ -681,6 +695,7 @@ export const DndContext = memo(function DndContext({
{children}
</ActiveDraggableContext.Provider>
</PublicContext.Provider>
<RestoreFocus disabled={accessibility?.restoreFocus === false} />
</InternalContext.Provider>
<Accessibility
{...accessibility}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/components/DndContext/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export {useMeasuringConfiguration} from './useMeasuringConfiguration';
export {useLayoutShiftScrollCompensation} from './useLayoutShiftScrollCompensation';
Original file line number Diff line number Diff line change
@@ -1,36 +1,11 @@
import {useMemo, useRef} from 'react';
import {useRef} from 'react';
import {useIsomorphicLayoutEffect} from '@dnd-kit/utilities';
import type {DeepRequired} from '@dnd-kit/utilities';

import {getRectDelta} from '../../utilities/rect';
import {getFirstScrollableAncestor} from '../../utilities/scroll';
import type {ClientRect} from '../../types';
import {defaultMeasuringConfiguration} from './defaults';
import type {MeasuringFunction, MeasuringConfiguration} from './types';
import type {DraggableNode} from '../../store';

export function useMeasuringConfiguration(
config: MeasuringConfiguration | undefined
): DeepRequired<MeasuringConfiguration> {
return useMemo(
() => ({
draggable: {
...defaultMeasuringConfiguration.draggable,
...config?.draggable,
},
droppable: {
...defaultMeasuringConfiguration.droppable,
...config?.droppable,
},
dragOverlay: {
...defaultMeasuringConfiguration.dragOverlay,
...config?.dragOverlay,
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[config?.draggable, config?.droppable, config?.dragOverlay]
);
}
import {getRectDelta} from '../../../utilities/rect';
import {getFirstScrollableAncestor} from '../../../utilities/scroll';
import type {ClientRect} from '../../../types';
import type {DraggableNode} from '../../../store';
import type {MeasuringFunction} from '../types';

interface Options {
activeNode: DraggableNode | null | undefined;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {useMemo} from 'react';
import type {DeepRequired} from '@dnd-kit/utilities';

import {defaultMeasuringConfiguration} from '../defaults';
import type {MeasuringConfiguration} from '../types';

export function useMeasuringConfiguration(
config: MeasuringConfiguration | undefined
): DeepRequired<MeasuringConfiguration> {
return useMemo(
() => ({
draggable: {
...defaultMeasuringConfiguration.draggable,
...config?.draggable,
},
droppable: {
...defaultMeasuringConfiguration.droppable,
...config?.droppable,
},
dragOverlay: {
...defaultMeasuringConfiguration.dragOverlay,
...config?.dragOverlay,
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[config?.draggable, config?.droppable, config?.dragOverlay]
);
}
4 changes: 3 additions & 1 deletion packages/core/src/hooks/useDraggable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,13 @@ export function useDraggable({
isDragging ? ActiveDraggableContext : NullContext
);
const [node, setNodeRef] = useNodeRef();
const [activatorNode, setActivatorNodeRef] = useNodeRef();
const listeners = useSyntheticListeners(activators, id);
const dataRef = useLatestValue(data);

useIsomorphicLayoutEffect(
() => {
draggableNodes[id] = {id, key, node, data: dataRef};
draggableNodes[id] = {id, key, node, activatorNode, data: dataRef};

return () => {
const node = draggableNodes[id];
Expand Down Expand Up @@ -101,6 +102,7 @@ export function useDraggable({
node,
over,
setNodeRef,
setActivatorNodeRef,
transform,
};
}
4 changes: 2 additions & 2 deletions packages/core/src/hooks/utilities/useCombineActivators.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {useMemo} from 'react';

import type {SensorDescriptor, SensorHandler} from '../../sensors';
import type {SensorActivatorFunction, SensorDescriptor} from '../../sensors';
import type {
SyntheticListener,
SyntheticListeners,
Expand All @@ -9,7 +9,7 @@ import type {
export function useCombineActivators(
sensors: SensorDescriptor<any>[],
getSyntheticHandler: (
handler: SensorHandler,
handler: SensorActivatorFunction<any>,
sensor: SensorDescriptor<any>
) => SyntheticListener['handler']
): SyntheticListeners {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/sensors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type {
Response as SensorResponse,
Sensor,
Sensors,
SensorActivatorFunction,
SensorDescriptor,
SensorContext,
SensorHandler,
Expand Down
Loading

0 comments on commit 59ca82b

Please sign in to comment.