Advanced
The surface beyond a draggable and a sortable list: watch drags globally, accept files from the OS, restrict where a drag starts, customize the preview, tune accessibility, and set defaults for a whole app.
@mmstack/dndnpm
#When to reach here
The draggables and drop targets page and the sortable lists page cover the common cases: carry a typed payload, narrow it on a target, splice an array on drop. This page is for the rest.
Reach for it to react to a drag from an element that is neither the source nor a target, accept files a user drags in from their desktop, start a drag from a grip inside a larger row, swap the image that follows the cursor, adjust the screen-reader narration, or set a convention like the pointer engine once instead of at every call site.
#Watch drags globally with monitor
monitor() observes the ambient drag session without the host being a draggable or a drop target. It returns isDragging() and source(), both derived from the session, so there is no subscription unless you ask for one. Use it for a drag-aware cursor, a "drop somewhere" hint, or a bit of analytics.
import { monitor } from '@mmstack/dnd';
type Card = { id: string; title: string };
const isCard = (d: unknown): d is Card =>
!!d && typeof d === 'object' && 'id' in d;
// derived: no subscription, recomputes only when the session changes
protected readonly drags = monitor<Card>({ accepts: isCard });
// template: <div [class.is-dragging]="drags.isDragging()">
// drags.source() is { data: Card; meta } | undefined while a Card drags Pass an accepts type guard to report only the drags you care about, and source() is narrowed to that type while a matching drag is in flight. If you also need a side effect at the edges, pass onDragStart or onDrop; those (and only those) attach a thin subscription. For a lower-level view of the same session there are injectDndActive(), injectDndPointer(), injectDndTargets(), and the writable injectDndSession().
#Files and external drops
fileDropTarget() makes the host accept files dragged in from outside the browser (the OS, another app). It derives isDragOver() and isInnermost() like a normal target, and onDrop hands you the extracted File[] through a FileDropEvent.
import { fileDropTarget } from '@mmstack/dnd';
@Component({ host: { '[class.over]': 'drop.isDragOver()' } })
export class Uploader {
protected readonly drop = fileDropTarget({
canDrop: ({ types }) => types.includes('Files'),
onDrop: ({ files }) => this.upload(files), // files: File[]
});
} A canDrop gate can inspect the drag's media types before accepting, and sticky / dropEffect behave as they do on a normal target. To watch external drags without being a target, use monitorExternal(), whose isDragging() tracks any file drag on the page.
import { monitorExternal } from '@mmstack/dnd';
// true whenever a file drag is anywhere on the page
protected readonly external = monitorExternal({
onDrop: ({ files }) => this.stash(files),
});This is a native-engine capability: files, cross-window drags, and the browser's own drag image all run through HTML5 drag-and-drop, which the native engine wraps. An element can be both a normal drop target and a file target at once. Apply both composables to the same host.
#Drag from a handle
By default the whole draggable element starts a drag. To limit that to a grip, put mmDragHandle on a child, capture it with a template reference, and pass it to the draggable's dragHandle input. The rest of the row stays clickable and selectable.
<li
mmDraggable
[data]="item()"
[dragHandle]="grip"
#d="mmDraggable"
[class.dragging]="d.dragging()"
>
<span mmDragHandle #grip="mmDragHandle">::</span>
{{ item().label }}
</li> This is the element-layer counterpart to the reorderable handle you saw on the sortable lists page. The directive is the DragHandle class; mmDragHandle scopes any mmDraggable, and the reorderable version does the same for a list row.
#Custom preview
The preview option on a draggable controls what follows the pointer. A PreviewConfig is one of three shapes: a component ({ component, bindings? }), a template ({ template, context? }), or a raw render callback for full control. Each takes an optional offset.
import { draggable } from '@mmstack/dnd';
// a template preview, anchored just off the cursor
protected readonly dnd = draggable<Card>({
data: this.card,
preview: () => ({
template: this.previewTpl(),
context: this.card(),
offset: 'pointer-outside',
}),
});
// or { component, bindings }, or { render } for a raw containerPreviewOffset is either 'pointer-outside' (sit just off the cursor) or a fixed { x, y }. On the native engine this renders into the browser's custom drag image; on the pointer engine there is no native image, so the same config renders a floating follower the library positions itself. The bindings array uses inputBinding, outputBinding, and twoWayBinding from @angular/core, so inputs stay reactive.
#Accessibility
Keyboard reordering and screen-reader announcements are on by default for reorderable lists. Focus a row, arrow keys move it one step, and the move is narrated through a shared ARIA live region. You do not opt in.
To narrate your own operations (a delete, a cross-list move, a custom command), call injectAnnounce(). It returns the active announcer: a plugin you registered through provideDnd, or the built-in zero-dependency one otherwise. The message defaults to 'polite'; pass 'assertive' (a Politeness value) for something that should interrupt.
import { injectAnnounce } from '@mmstack/dnd';
const announce = injectAnnounce();
reorderable(this.items, {
key: (c) => c.id,
onReorder: ({ to, items }) =>
announce(`${items[to].label} moved to position ${to + 1}`),
});
// something urgent should interrupt:
announce('Upload failed', 'assertive'); To opt out on a list, set keyboard: false (no tabindex, no handler) or announceMove: false (no live region). To replace the announcer app-wide, register an announce plugin (for example Atlassian's live-region package) through provideDnd, and every injectAnnounce() call picks it up.
import { provideDnd } from '@mmstack/dnd';
import { announce as liveRegionAnnounce } from '@atlaskit/pragmatic-drag-and-drop-live-region';
provideDnd({ plugins: { announce: liveRegionAnnounce } });
// every injectAnnounce() now returns this announcer instead of the built-in#Auto-scroll
When a drop area is taller than its viewport, you want it to scroll as the pointer nears an edge. The mmAutoScroll directive turns that on for a scrollable host, and autoScroll() is its composable form for a component that owns the element. It needs an auto-scroll plugin registered (see App-wide defaults below); without one it warns once in dev and no-ops.
<!-- directive on a scrollable container -->
<div mmAutoScroll style="overflow:auto; max-height:300px">…</div>
<!-- or the composable form, for a component that owns the element -->
<!-- import { autoScroll } from '@mmstack/dnd'; constructor() { autoScroll(); } -->AutoScrollOptions lets you point it at a different element, override the resolved plugin, or pass an injector to run outside an injection context. Any extra keys pass through to the plugin. This is the element-layer scroll: a reorderable list has its own autoScroll option that drives the same plugin, so you rarely need this directive on a sortable.
#Drop indicator
On the native engine a sortable does not move its items; it draws an insertion line where the drop will land. That line is the DropIndicator component, <mm-drop-indicator>. The reorderable directives render it for you, so you only reach for it directly on a hand-built native drop target: overlay it on a position: relative element and drive its edge input from a target's closestEdge().
<li
mmDropTarget
#dt="mmDropTarget"
[edges]="['top', 'bottom']"
style="position: relative"
>
…
<mm-drop-indicator [edge]="dt.closestEdge()" />
</li> It positions itself with encapsulated styles (no global stylesheet), and reads the edge reactively, so binding [edge]="dt.closestEdge()" is all it takes. Color comes from the --mm-drop-indicator-color custom property.
#Scoped sessions
The drag session lives in the root injector, so the library works with no setup and a drag on one part of the page is visible everywhere. When a subtree needs its own coordinate space, one that does not see or interfere with drags elsewhere, add provideDndSession() to that component's providers. The injectDnd* helpers inside it then resolve to the scoped session instead of the root one.
import { provideDndSession } from '@mmstack/dnd';
@Component({
providers: [provideDndSession()], // an independent session for this subtree
})
export class BoardComponent {}#App-wide defaults
Two provider families let a team set conventions once. Use provideDnd() to register the optional plugins (edge detection, auto-scroll, post-move flash, a replacement announcer) and to scope a session. Use the defaults providers to fill option values so you stop repeating them at every call.
import { provideDnd } from '@mmstack/dnd';
import { closestEdge, edgeAutoScroll } from '@mmstack/dnd/plugins';
bootstrapApplication(App, {
providers: [
provideDnd({
plugins: {
hitbox: closestEdge, // enables edge-aware drops
autoScroll: edgeAutoScroll, // scroll near a container edge
},
}),
],
});provideDndDefaults() holds the cross-primitive defaults (currently engine), so one line flips your whole app to the pointer engine. Each primitive also has its own provider for options only it understands, and it inherits the common defaults unless it sets that key itself.
import {
provideDndDefaults,
provideDraggableDefaults,
provideDropTargetDefaults,
} from '@mmstack/dnd';
provideDndDefaults({ engine: 'pointer' }); // every primitive goes pointer
provideDraggableDefaults({ engine: 'native' }); // except draggables, kept native
provideDropTargetDefaults({ sticky: true, dropEffect: 'copy' }); Resolution runs most-specific first: per-call option, then the per-primitive default, then provideDndDefaults, then the built-in. Each provider takes a value or a factory, and each has a matching reader (injectDndDefaults and friends) that returns the resolved defaults or null. Pass an Injector to read outside an injection context.
#Testing
Because per-element state is derived from the one ambient session, most behaviour is unit-testable with no drag simulation: set the session and assert the derived signals. injectDndSession() returns the writable session, and boxData builds a source payload the way the library boxes it internally (under a private symbol), so a target's accepts and the derived isDragOver() see the real shape.
import { injectDndSession, boxData } from '@mmstack/dnd';
const session = TestBed.runInInjectionContext(() => injectDndSession());
session.set({
sourceEl,
sourceData: boxData(card), // boxed the same way the library boxes it
targets: [{ element: el, data: {} }],
pointer: { x: 0, y: 0 },
kind: 'transfer',
engine: 'native',
});
expect(zone.isDragOver()).toBe(true); The reorderable controller is testable the same way, and without a DOM. reorderable(signal, opts) is a pure controller: drive its begin / move / end and read the per-item state signals directly.
#Open core
The primitives on these pages cover the common shapes, but drag-and-drop has a long tail. When a shipped primitive does not fit, you do not have to rebuild the plumbing. The barrel also exports the pieces the library is built from: the gesture chassis (driveGesture) and the geometry and hit-testing helpers behind the sortable, grid, and canvas, that way you can assemble a bespoke interaction on the same reactive session everything else reads.
These helpers are not documented symbol by symbol, on purpose: keeping them out of the reference keeps the surface you learn small. They are typed and discoverable straight from the barrel, so an editor autocomplete on @mmstack/dnd is the map.