diff --git a/.changeset/activator-node-ref.md b/.changeset/activator-node-ref.md
new file mode 100644
index 00000000..3686b207
--- /dev/null
+++ b/.changeset/activator-node-ref.md
@@ -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 (
+
+ Draggable element
+
+
+ )
+}
+```
+
+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 ``:
+
+```diff
+ {
+ 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;
+}
diff --git a/packages/core/src/components/Accessibility/components/index.ts b/packages/core/src/components/Accessibility/components/index.ts
new file mode 100644
index 00000000..a57fe00a
--- /dev/null
+++ b/packages/core/src/components/Accessibility/components/index.ts
@@ -0,0 +1 @@
+export {RestoreFocus} from './RestoreFocus';
diff --git a/packages/core/src/components/Accessibility/index.ts b/packages/core/src/components/Accessibility/index.ts
index f4e0ec39..75803de8 100644
--- a/packages/core/src/components/Accessibility/index.ts
+++ b/packages/core/src/components/Accessibility/index.ts
@@ -1,4 +1,5 @@
export {Accessibility} from './Accessibility';
+export {RestoreFocus} from './components';
export {
defaultAnnouncements,
defaultScreenReaderInstructions,
diff --git a/packages/core/src/components/DndContext/DndContext.tsx b/packages/core/src/components/DndContext/DndContext.tsx
index ed4bc481..38fea15f 100644
--- a/packages/core/src/components/DndContext/DndContext.tsx
+++ b/packages/core/src/components/DndContext/DndContext.tsx
@@ -50,7 +50,7 @@ import type {
Sensor,
SensorContext,
SensorDescriptor,
- SensorHandler,
+ SensorActivatorFunction,
SensorInstance,
} from '../../sensors';
import {
@@ -74,6 +74,7 @@ import type {
import {
Accessibility,
Announcements,
+ RestoreFocus,
ScreenReaderInstructions,
} from '../Accessibility';
@@ -89,6 +90,7 @@ export interface Props {
accessibility?: {
announcements?: Announcements;
container?: Element;
+ restoreFocus?: boolean;
screenReaderInstructions?: ScreenReaderInstructions;
};
autoScroll?: boolean | AutoScrollOptions;
@@ -447,15 +449,18 @@ export const DndContext = memo(function DndContext({
const bindActivatorToSensorInstantiator = useCallback(
(
- handler: SensorHandler,
+ handler: SensorActivatorFunction,
sensor: SensorDescriptor
): 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
@@ -463,7 +468,16 @@ export const DndContext = memo(function DndContext({
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,
};
@@ -473,7 +487,7 @@ export const DndContext = memo(function DndContext({
}
};
},
- [instantiateSensor]
+ [draggableNodes, instantiateSensor]
);
const activators = useCombineActivators(
@@ -681,6 +695,7 @@ export const DndContext = memo(function DndContext({
{children}
+ {
- 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;
diff --git a/packages/core/src/components/DndContext/hooks/useMeasuringConfiguration.ts b/packages/core/src/components/DndContext/hooks/useMeasuringConfiguration.ts
new file mode 100644
index 00000000..85f0cf84
--- /dev/null
+++ b/packages/core/src/components/DndContext/hooks/useMeasuringConfiguration.ts
@@ -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 {
+ 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]
+ );
+}
diff --git a/packages/core/src/hooks/useDraggable.ts b/packages/core/src/hooks/useDraggable.ts
index 13bb2445..b91d40bb 100644
--- a/packages/core/src/hooks/useDraggable.ts
+++ b/packages/core/src/hooks/useDraggable.ts
@@ -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];
@@ -101,6 +102,7 @@ export function useDraggable({
node,
over,
setNodeRef,
+ setActivatorNodeRef,
transform,
};
}
diff --git a/packages/core/src/hooks/utilities/useCombineActivators.ts b/packages/core/src/hooks/utilities/useCombineActivators.ts
index 253303b9..cac4a2b7 100644
--- a/packages/core/src/hooks/utilities/useCombineActivators.ts
+++ b/packages/core/src/hooks/utilities/useCombineActivators.ts
@@ -1,6 +1,6 @@
import {useMemo} from 'react';
-import type {SensorDescriptor, SensorHandler} from '../../sensors';
+import type {SensorActivatorFunction, SensorDescriptor} from '../../sensors';
import type {
SyntheticListener,
SyntheticListeners,
@@ -9,7 +9,7 @@ import type {
export function useCombineActivators(
sensors: SensorDescriptor[],
getSyntheticHandler: (
- handler: SensorHandler,
+ handler: SensorActivatorFunction,
sensor: SensorDescriptor
) => SyntheticListener['handler']
): SyntheticListeners {
diff --git a/packages/core/src/sensors/index.ts b/packages/core/src/sensors/index.ts
index 72354435..854cf591 100644
--- a/packages/core/src/sensors/index.ts
+++ b/packages/core/src/sensors/index.ts
@@ -32,6 +32,7 @@ export type {
Response as SensorResponse,
Sensor,
Sensors,
+ SensorActivatorFunction,
SensorDescriptor,
SensorContext,
SensorHandler,
diff --git a/packages/core/src/sensors/keyboard/KeyboardSensor.ts b/packages/core/src/sensors/keyboard/KeyboardSensor.ts
index 64a7375c..46d65ffc 100644
--- a/packages/core/src/sensors/keyboard/KeyboardSensor.ts
+++ b/packages/core/src/sensors/keyboard/KeyboardSensor.ts
@@ -15,7 +15,12 @@ import {
import {scrollIntoViewIfNeeded} from '../../utilities/scroll';
import {EventName} from '../events';
import {Listeners} from '../utilities';
-import type {SensorInstance, SensorProps, SensorOptions} from '../types';
+import type {
+ Activators,
+ SensorInstance,
+ SensorProps,
+ SensorOptions,
+} from '../types';
import {KeyboardCoordinateGetter, KeyboardCode, KeyboardCodes} from './types';
import {
@@ -263,19 +268,23 @@ export class KeyboardSensor implements SensorInstance {
this.windowListeners.removeAll();
}
- static activators = [
+ static activators: Activators = [
{
eventName: 'onKeyDown' as const,
handler: (
event: React.KeyboardEvent,
- {
- keyboardCodes = defaultKeyboardCodes,
- onActivation,
- }: KeyboardSensorOptions
+ {keyboardCodes = defaultKeyboardCodes, onActivation},
+ {active}
) => {
const {code} = event.nativeEvent;
if (keyboardCodes.start.includes(code)) {
+ const activator = active.activatorNode.current;
+
+ if (activator && event.target !== activator) {
+ return false;
+ }
+
event.preventDefault();
onActivation?.({event: event.nativeEvent});
diff --git a/packages/core/src/sensors/types.ts b/packages/core/src/sensors/types.ts
index f4f3259d..ef72e980 100644
--- a/packages/core/src/sensors/types.ts
+++ b/packages/core/src/sensors/types.ts
@@ -55,9 +55,17 @@ export type SensorInstance = {
autoScrollEnabled: boolean;
};
+export type SensorActivatorFunction = (
+ event: any,
+ options: T,
+ context: {
+ active: DraggableNode;
+ }
+) => boolean | undefined;
+
export type Activator = {
eventName: SyntheticEventName;
- handler(event: React.SyntheticEvent, options: T): boolean | undefined;
+ handler: SensorActivatorFunction;
};
export type Activators = Activator[];
diff --git a/packages/core/src/store/types.ts b/packages/core/src/store/types.ts
index dece363a..d0c95b30 100644
--- a/packages/core/src/store/types.ts
+++ b/packages/core/src/store/types.ts
@@ -50,6 +50,7 @@ export type DraggableNode = {
id: UniqueIdentifier;
key: UniqueIdentifier;
node: MutableRefObject;
+ activatorNode: MutableRefObject;
data: DataRef;
};
diff --git a/packages/sortable/src/hooks/useSortable.ts b/packages/sortable/src/hooks/useSortable.ts
index a87712e9..9f0a87ba 100644
--- a/packages/sortable/src/hooks/useSortable.ts
+++ b/packages/sortable/src/hooks/useSortable.ts
@@ -81,6 +81,7 @@ export function useSortable({
listeners,
isDragging,
over,
+ setActivatorNodeRef,
transform,
} = useDraggable({
id,
@@ -179,6 +180,7 @@ export function useSortable({
overIndex,
over,
setNodeRef,
+ setActivatorNodeRef,
setDroppableNodeRef,
setDraggableNodeRef,
transform: derivedTransform ?? finalTransform,
diff --git a/packages/utilities/src/focus/findFirstFocusableNode.ts b/packages/utilities/src/focus/findFirstFocusableNode.ts
new file mode 100644
index 00000000..d7d9e3c2
--- /dev/null
+++ b/packages/utilities/src/focus/findFirstFocusableNode.ts
@@ -0,0 +1,12 @@
+const SELECTOR =
+ 'a,frame,iframe,input:not([type=hidden]):not(:disabled),select:not(:disabled),textarea:not(:disabled),button:not(:disabled),*[tabindex]';
+
+export function findFirstFocusableNode(
+ element: HTMLElement
+): HTMLElement | null {
+ if (element.matches(SELECTOR)) {
+ return element;
+ }
+
+ return element.querySelector(SELECTOR);
+}
diff --git a/packages/utilities/src/focus/index.ts b/packages/utilities/src/focus/index.ts
new file mode 100644
index 00000000..1363b995
--- /dev/null
+++ b/packages/utilities/src/focus/index.ts
@@ -0,0 +1 @@
+export {findFirstFocusableNode} from './findFirstFocusableNode';
diff --git a/packages/utilities/src/index.ts b/packages/utilities/src/index.ts
index 2afab4a5..c1b3a597 100644
--- a/packages/utilities/src/index.ts
+++ b/packages/utilities/src/index.ts
@@ -21,6 +21,7 @@ export {
isTouchEvent,
} from './event';
export {canUseDOM, getOwnerDocument, getWindow} from './execution-context';
+export {findFirstFocusableNode} from './focus';
export {
isDocument,
isHTMLElement,
diff --git a/stories/2 - Presets/Sortable/4-MultipleContainers.story.tsx b/stories/2 - Presets/Sortable/4-MultipleContainers.story.tsx
index 3e7cd841..20601f06 100644
--- a/stories/2 - Presets/Sortable/4-MultipleContainers.story.tsx
+++ b/stories/2 - Presets/Sortable/4-MultipleContainers.story.tsx
@@ -12,6 +12,8 @@ export default {
export const BasicSetup = () => ;
+export const DragHandle = () => ;
+
export const ManyItems = () => (
) : null}
- {handle ? : null}
+ {handle ? : null}
diff --git a/stories/components/Item/components/Action/Action.tsx b/stories/components/Item/components/Action/Action.tsx
index 5faee689..9fcfe077 100644
--- a/stories/components/Item/components/Action/Action.tsx
+++ b/stories/components/Item/components/Action/Action.tsx
@@ -1,4 +1,4 @@
-import React, {CSSProperties} from 'react';
+import React, {forwardRef, CSSProperties} from 'react';
import classNames from 'classnames';
import styles from './Action.module.css';
@@ -11,20 +11,23 @@ export interface Props extends React.HTMLAttributes {
cursor?: CSSProperties['cursor'];
}
-export function Action({active, className, cursor, style, ...props}: Props) {
- return (
-
- );
-}
+export const Action = forwardRef(
+ ({active, className, cursor, style, ...props}, ref) => {
+ return (
+
+ );
+ }
+);
diff --git a/stories/components/Item/components/Handle/Handle.tsx b/stories/components/Item/components/Handle/Handle.tsx
index 7e8af75b..a6508f93 100644
--- a/stories/components/Item/components/Handle/Handle.tsx
+++ b/stories/components/Item/components/Handle/Handle.tsx
@@ -1,13 +1,20 @@
-import React from 'react';
+import React, {forwardRef} from 'react';
import {Action, ActionProps} from '../Action';
-export function Handle(props: ActionProps) {
- return (
-
-
-
- );
-}
+export const Handle = forwardRef(
+ (props, ref) => {
+ return (
+
+
+
+ );
+ }
+);