Skip to content

Commit

Permalink
Merge pull request #1539 from irobot/feature/constraint-cues
Browse files Browse the repository at this point in the history
Make it easier to implement visual cues when using activation constraints.
  • Loading branch information
clauderic authored Nov 23, 2024
2 parents 2ed0363 + 9175566 commit d9aed39
Show file tree
Hide file tree
Showing 11 changed files with 321 additions and 6 deletions.
58 changes: 58 additions & 0 deletions .changeset/metal-mice-develop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
'@dnd-kit/core': minor
---

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.
34 changes: 34 additions & 0 deletions packages/core/src/components/DndContext/DndContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ import type {
DragMoveEvent,
DragOverEvent,
UniqueIdentifier,
DragPendingEvent,
DragAbortEvent,
} from '../../types';
import {
Accessibility,
Expand Down Expand Up @@ -100,6 +102,8 @@ export interface Props {
measuring?: MeasuringConfiguration;
modifiers?: Modifiers;
sensors?: SensorDescriptor<any>[];
onDragAbort?(event: DragAbortEvent): void;
onDragPending?(event: DragPendingEvent): void;
onDragStart?(event: DragStartEvent): void;
onDragMove?(event: DragMoveEvent): void;
onDragOver?(event: DragOverEvent): void;
Expand Down Expand Up @@ -345,6 +349,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;

Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/components/DndMonitor/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type {
DragAbortEvent,
DragPendingEvent,
DragStartEvent,
DragCancelEvent,
DragEndEvent,
Expand All @@ -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;
Expand All @@ -17,6 +21,8 @@ export interface DndMonitorListener {
export interface DndMonitorEvent {
type: keyof DndMonitorListener;
event:
| DragAbortEvent
| DragPendingEvent
| DragStartEvent
| DragMoveEvent
| DragOverEvent
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export type {
DragMoveEvent,
DragOverEvent,
DragStartEvent,
DragPendingEvent,
DragAbortEvent,
DragCancelEvent,
Translate,
UniqueIdentifier,
Expand Down
21 changes: 19 additions & 2 deletions packages/core/src/sensors/pointer/AbstractPointerSensor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,12 @@ export class AbstractPointerSensor implements SensorInstance {
this.handleStart,
activationConstraint.delay
);
this.handlePending(activationConstraint);
return;
}

if (isDistanceConstraint(activationConstraint)) {
this.handlePending(activationConstraint);
return;
}
}
Expand All @@ -162,6 +164,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;
Expand Down Expand Up @@ -222,6 +232,7 @@ export class AbstractPointerSensor implements SensorInstance {
}
}

this.handlePending(activationConstraint, delta);
return;
}

Expand All @@ -233,16 +244,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();
}

Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/sensors/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -46,6 +47,13 @@ export interface SensorProps<T> {
event: Event;
context: MutableRefObject<SensorContext>;
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;
Expand Down
24 changes: 23 additions & 1 deletion packages/core/src/types/events.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<DragEvent, 'active' | 'activatorEvent'> {}

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export type {
} from './coordinates';
export {Direction} from './direction';
export type {
DragAbortEvent,
DragPendingEvent,
DragStartEvent,
DragCancelEvent,
DragEndEvent,
Expand Down
Loading

0 comments on commit d9aed39

Please sign in to comment.