From 0c6a28d1b32c72cfbc6e103c9f430a1e8ebe7301 Mon Sep 17 00:00:00 2001 From: pax Date: Fri, 22 Nov 2024 18:58:53 +0000 Subject: [PATCH] Enable visual cues when using activation constraints. --- .changeset/metal-mice-develop.md | 58 +++++++ .../src/components/DndContext/DndContext.tsx | 34 ++++ .../core/src/components/DndMonitor/types.ts | 6 + packages/core/src/index.ts | 2 + .../sensors/pointer/AbstractPointerSensor.ts | 21 ++- packages/core/src/sensors/types.ts | 8 + packages/core/src/types/events.ts | 24 ++- packages/core/src/types/index.ts | 2 + .../1 - Core/Draggable/1-Draggable.story.tsx | 146 +++++++++++++++++- .../components/Draggable/Draggable.module.css | 19 +++ stories/components/Draggable/Draggable.tsx | 7 +- 11 files changed, 321 insertions(+), 6 deletions(-) create mode 100644 .changeset/metal-mice-develop.md diff --git a/.changeset/metal-mice-develop.md b/.changeset/metal-mice-develop.md new file mode 100644 index 00000000..bfc2bd45 --- /dev/null +++ b/.changeset/metal-mice-develop.md @@ -0,0 +1,58 @@ +--- +'@dnd-kit/core': patch +--- + +Make it possible to add visual cues when using activation constraints. + +### Context + +Activation constraints are used when we want to prevent accidental dragging or when +pointer press can mean more than "start dragging". + +A typical use case is a button that needs to respond to both "click" and "drag" gestures. +Clicks can be distinguished from drags based on how long the pointer was +held pressed. + +### The problem + +A control that responds differently to a pointer press based on duration or distance can +be confusing to use -- the user has to guess how long to keep holding or how far to keep +dragging until their intent is acknowledged. + +Implementing such cues is currently possible by attaching extra event listeners so that +we know when a drag is pending. Furthermore, the listener needs to have access to +the same constraints that were applied to the sensor initiating the drag. This can be +made to work in simple cases, but it becomes error-prone and difficult to maintain in +complex scenarios. + +### Solution + +This changeset proposes the addition of two new events: `onDragPending` and `onDragAbort`. + +#### `onDragPending` + +A drag is considered to be pending when the pointer has been pressed and there are +activation constraints that need to be satisfied before a drag can start. + +This event is initially fired on pointer press. At this time `offset` (see below) will be +`undefined`. + +It will subsequently be fired every time the pointer is moved. This is to enable +visual cues for distance-based activation. + +The event's payload contains all the information necessary for providing visual feedback: + +```typescript +export interface DragPendingEvent { + id: UniqueIdentifier; + constraint: PointerActivationConstraint; + initialCoordinates: Coordinates; + offset?: Coordinates | undefined; +} +``` + +#### `onDragAbort` + +A drag is considered aborted when an activation constraint for a pending drag was violated. +Useful as a prompt to cancel any visual cue animations currently in progress. +Note that this event will _not_ be fired when dragging ends or is canceled. diff --git a/packages/core/src/components/DndContext/DndContext.tsx b/packages/core/src/components/DndContext/DndContext.tsx index 9d5df10d..fe62b0e2 100644 --- a/packages/core/src/components/DndContext/DndContext.tsx +++ b/packages/core/src/components/DndContext/DndContext.tsx @@ -70,6 +70,8 @@ import type { DragMoveEvent, DragOverEvent, UniqueIdentifier, + DragPendingEvent, + DragAbortEvent, } from '../../types'; import { Accessibility, @@ -100,6 +102,8 @@ export interface Props { measuring?: MeasuringConfiguration; modifiers?: Modifiers; sensors?: SensorDescriptor[]; + onDragAbort?(event: DragAbortEvent): void; + onDragPending?(event: DragPendingEvent): void; onDragStart?(event: DragStartEvent): void; onDragMove?(event: DragMoveEvent): void; onDragOver?(event: DragOverEvent): void; @@ -344,6 +348,36 @@ export const DndContext = memo(function DndContext({ // Sensors need to be instantiated with refs for arguments that change over time // otherwise they are frozen in time with the stale arguments context: sensorContext, + onAbort(id) { + const draggableNode = draggableNodes.get(id); + + if (!draggableNode) { + return; + } + + const {onDragAbort} = latestProps.current; + const event: DragAbortEvent = {id}; + onDragAbort?.(event); + dispatchMonitorEvent({type: 'onDragAbort', event}); + }, + onPending(id, constraint, initialCoordinates, offset) { + const draggableNode = draggableNodes.get(id); + + if (!draggableNode) { + return; + } + + const {onDragPending} = latestProps.current; + const event: DragPendingEvent = { + id, + constraint, + initialCoordinates, + offset, + }; + + onDragPending?.(event); + dispatchMonitorEvent({type: 'onDragPending', event}); + }, onStart(initialCoordinates) { const id = activeRef.current; diff --git a/packages/core/src/components/DndMonitor/types.ts b/packages/core/src/components/DndMonitor/types.ts index 0d7c2655..4b9a4428 100644 --- a/packages/core/src/components/DndMonitor/types.ts +++ b/packages/core/src/components/DndMonitor/types.ts @@ -1,4 +1,6 @@ import type { + DragAbortEvent, + DragPendingEvent, DragStartEvent, DragCancelEvent, DragEndEvent, @@ -7,6 +9,8 @@ import type { } from '../../types'; export interface DndMonitorListener { + onDragAbort?(event: DragAbortEvent): void; + onDragPending?(event: DragPendingEvent): void; onDragStart?(event: DragStartEvent): void; onDragMove?(event: DragMoveEvent): void; onDragOver?(event: DragOverEvent): void; @@ -17,6 +21,8 @@ export interface DndMonitorListener { export interface DndMonitorEvent { type: keyof DndMonitorListener; event: + | DragAbortEvent + | DragPendingEvent | DragStartEvent | DragMoveEvent | DragOverEvent diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 304f8b1a..857f0da0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -97,6 +97,8 @@ export type { DragMoveEvent, DragOverEvent, DragStartEvent, + DragPendingEvent, + DragAbortEvent, DragCancelEvent, Translate, UniqueIdentifier, diff --git a/packages/core/src/sensors/pointer/AbstractPointerSensor.ts b/packages/core/src/sensors/pointer/AbstractPointerSensor.ts index 5c39f6de..30afb814 100644 --- a/packages/core/src/sensors/pointer/AbstractPointerSensor.ts +++ b/packages/core/src/sensors/pointer/AbstractPointerSensor.ts @@ -131,10 +131,12 @@ export class AbstractPointerSensor implements SensorInstance { this.handleStart, activationConstraint.delay ); + this.handlePending(activationConstraint); return; } if (isDistanceConstraint(activationConstraint)) { + this.handlePending(activationConstraint); return; } } @@ -156,6 +158,14 @@ export class AbstractPointerSensor implements SensorInstance { } } + private handlePending( + constraint: PointerActivationConstraint, + offset?: Coordinates | undefined + ): void { + const {active, onPending} = this.props; + onPending(active, constraint, this.initialCoordinates, offset); + } + private handleStart() { const {initialCoordinates} = this; const {onStart} = this.props; @@ -216,6 +226,7 @@ export class AbstractPointerSensor implements SensorInstance { } } + this.handlePending(activationConstraint, delta); return; } @@ -227,16 +238,22 @@ export class AbstractPointerSensor implements SensorInstance { } private handleEnd() { - const {onEnd} = this.props; + const {onAbort, onEnd} = this.props; this.detach(); + if (!this.activated) { + onAbort(this.props.active); + } onEnd(); } private handleCancel() { - const {onCancel} = this.props; + const {onAbort, onCancel} = this.props; this.detach(); + if (!this.activated) { + onAbort(this.props.active); + } onCancel(); } diff --git a/packages/core/src/sensors/types.ts b/packages/core/src/sensors/types.ts index d4a513cd..38e10dba 100644 --- a/packages/core/src/sensors/types.ts +++ b/packages/core/src/sensors/types.ts @@ -15,6 +15,7 @@ import type { ClientRect, } from '../types'; import type {Collision} from '../utilities/algorithms'; +import type {PointerActivationConstraint} from './pointer'; export enum Response { Start = 'start', @@ -46,6 +47,13 @@ export interface SensorProps { event: Event; context: MutableRefObject; options: T; + onAbort(id: UniqueIdentifier): void; + onPending( + id: UniqueIdentifier, + constraint: PointerActivationConstraint, + initialCoordinates: Coordinates, + offset?: Coordinates | undefined + ): void; onStart(coordinates: Coordinates): void; onCancel(): void; onMove(coordinates: Coordinates): void; diff --git a/packages/core/src/types/events.ts b/packages/core/src/types/events.ts index 5c7c2aae..e96e6e24 100644 --- a/packages/core/src/types/events.ts +++ b/packages/core/src/types/events.ts @@ -1,7 +1,9 @@ +import type {PointerActivationConstraint} from '../sensors'; import type {Active, Over} from '../store'; import type {Collision} from '../utilities/algorithms'; -import type {Translate} from './coordinates'; +import type {Coordinates, Translate} from './coordinates'; +import type {UniqueIdentifier} from '.'; interface DragEvent { activatorEvent: Event; @@ -11,6 +13,26 @@ interface DragEvent { over: Over | null; } +/** + * Fired if a pending drag was aborted before it started. + * Only meaningful in the context of activation constraints. + **/ +export interface DragAbortEvent { + id: UniqueIdentifier; +} + +/** + * Fired when a drag is about to start pending activation constraints. + * @note For pointer events, it will be fired repeatedly with updated + * coordinates when pointer is moved until the drag starts. + */ +export interface DragPendingEvent { + id: UniqueIdentifier; + constraint: PointerActivationConstraint; + initialCoordinates: Coordinates; + offset?: Coordinates | undefined; +} + export interface DragStartEvent extends Pick {} export interface DragMoveEvent extends DragEvent {} diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index ecd775ba..67f76d95 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -6,6 +6,8 @@ export type { } from './coordinates'; export {Direction} from './direction'; export type { + DragAbortEvent, + DragPendingEvent, DragStartEvent, DragCancelEvent, DragEndEvent, diff --git a/stories/1 - Core/Draggable/1-Draggable.story.tsx b/stories/1 - Core/Draggable/1-Draggable.story.tsx index fc002194..61e41e74 100644 --- a/stories/1 - Core/Draggable/1-Draggable.story.tsx +++ b/stories/1 - Core/Draggable/1-Draggable.story.tsx @@ -1,4 +1,4 @@ -import React, {useMemo, useState} from 'react'; +import React, {useCallback, useMemo, useRef, useState} from 'react'; import { DndContext, useDraggable, @@ -9,6 +9,8 @@ import { PointerActivationConstraint, Modifiers, useSensors, + type DragPendingEvent, + useDndMonitor, } from '@dnd-kit/core'; import { createSnapModifier, @@ -18,6 +20,7 @@ import { snapCenterToCursor, } from '@dnd-kit/modifiers'; import type {Coordinates} from '@dnd-kit/utilities'; +import type {StoryObj} from '@storybook/react'; import { Axis, @@ -44,6 +47,7 @@ interface Props { buttonStyle?: React.CSSProperties; style?: React.CSSProperties; label?: string; + showConstraintCue?: boolean; } function DraggableStory({ @@ -54,6 +58,7 @@ function DraggableStory({ modifiers, style, buttonStyle, + showConstraintCue, }: Props) { const [{x, y}, setCoordinates] = useState(defaultCoordinates); const mouseSensor = useSensor(MouseSensor, { @@ -64,6 +69,7 @@ function DraggableStory({ }); const keyboardSensor = useSensor(KeyboardSensor, {}); const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor); + const Item = showConstraintCue ? DraggableItemWithVisualCue : DraggableItem; return ( - (null); + const distanceCueRef = useRef(null); + + const handlePending = useCallback( + (event: DragPendingEvent) => { + setIsPending(true); + const {constraint} = event; + if ('delay' in constraint) { + setPendingDelay(constraint.delay); + } + if ('distance' in constraint && typeof constraint.distance === 'number') { + const {distance} = constraint; + if (event.offset === undefined && node.current !== null) { + // Infer the position of the pointer relative to the element. + // Only do this once at the start, as the offset is defined + // when the pointer moves. + const {x: rx, y: ry} = node.current.getBoundingClientRect(); + setDistanceCue({ + x: event.initialCoordinates.x - rx - distance, + y: event.initialCoordinates.y - ry - distance, + size: distance * 2, + }); + } + if (distanceCueRef.current === null) { + return; + } + const {x, y} = event.offset ?? {x: 0, y: 0}; + const length = Math.sqrt(x * x + y * y); + const ratio = length / Math.max(distance, 1); + const fanAngle = 360 * (1 - ratio); + const rotation = Math.atan2(y, x) * (180 / Math.PI) - 90 - fanAngle / 2; + const {style} = distanceCueRef.current; + style.setProperty( + 'background-image', + `conic-gradient(red ${fanAngle}deg, transparent 0deg)` + ); + style.setProperty('rotate', `${rotation}deg`); + style.setProperty('opacity', `${0.25 + ratio * 0.75}`); + } + }, + [node] + ); + + const handlePendingEnd = useCallback(() => setIsPending(false), []); + + useDndMonitor({ + onDragPending: handlePending, + onDragAbort: handlePendingEnd, + onDragCancel: handlePendingEnd, + onDragEnd: handlePendingEnd, + }); + + const pendingStyle: React.CSSProperties = isPending + ? {animationDuration: `${pendingDelayMs}ms`} + : {}; + + return ( + <> + 0} + transform={transform} + axis={props.axis} + {...attributes} + > + {isPending && !isDragging && distanceCue && ( +
+ )} +
+ + ); +} + export const BasicSetup = () => ; export const DragHandle = () => ( @@ -164,6 +269,29 @@ export const PressDelayOrDistance = () => ( PressDelayOrDistance.storyName = 'Press delay or minimum distance'; +type PressDelayWithVisualCueArgs = { + delay: number; + tolerance: number; +}; + +export const PressDelayWithVisualCue: StoryObj = { + render: (args) => ( + + ), + args: {delay: 500, tolerance: 5}, + argTypes: { + delay: { + name: 'Delay (ms)', + control: {type: 'range', min: 250, max: 1000, step: 50}, + }, + tolerance: {control: 'number', name: 'Tolerance (px)'}, + }, +}; + export const MinimumDistance = () => ( ( /> ); +export const MinimumDistanceWithVisualCue: StoryObj<{ + distance: number; +}> = { + render: (args) => ( + + ), + args: {distance: 60}, + argTypes: {distance: {control: {type: 'range', min: 10, max: 80}}}, +}; + export const MinimumDistanceX = () => ( "; + initial-value: 0; + inherits: false; +} + .Draggable { position: relative; display: flex; @@ -24,6 +30,13 @@ transition: box-shadow 300ms ease; } + &.pendingDelay > button { + animation: pending linear; + background-image: linear-gradient(90deg, + #f00d calc(var(--progress)* 1%), + transparent calc(var(--progress)* 1% + 1%)); + } + &:not(.handle) { > button { touch-action: none; @@ -128,3 +141,9 @@ box-shadow: var(--box-shadow); } } + +@keyframes pending { + 100% { + --progress: 100; + } +} \ No newline at end of file diff --git a/stories/components/Draggable/Draggable.tsx b/stories/components/Draggable/Draggable.tsx index 00651122..f975aea7 100644 --- a/stories/components/Draggable/Draggable.tsx +++ b/stories/components/Draggable/Draggable.tsx @@ -28,6 +28,8 @@ interface Props { style?: React.CSSProperties; buttonStyle?: React.CSSProperties; transform?: Transform | null; + isPendingDelay?: boolean; + children?: React.ReactNode; } export const Draggable = forwardRef( @@ -42,6 +44,7 @@ export const Draggable = forwardRef( transform, style, buttonStyle, + isPendingDelay = false, ...props }, ref @@ -52,7 +55,8 @@ export const Draggable = forwardRef( styles.Draggable, dragOverlay && styles.dragOverlay, dragging && styles.dragging, - handle && styles.handle + handle && styles.handle, + isPendingDelay && styles.pendingDelay )} style={ { @@ -77,6 +81,7 @@ export const Draggable = forwardRef( ? draggableHorizontal : draggable} {handle ? : null} + {props.children} {label ? : null}