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 ( -