Draggables and drop targets
The element layer. mmDraggable carries a typed payload, mmDropTarget narrows it and exposes drag state, and the drop updates your own signal.
@mmstack/dndnpm
Demo loads on scroll.
#Draggable
Put mmDraggable on an element and give it data. That value is the payload a drop target receives. Read dragging() from the directive to style the element while it moves.
<article
mmDraggable
#d="mmDraggable"
[data]="card"
[class.dragging]="d.dragging()"
>
{{ card.title }}
</article>#Drop target
mmDropTarget takes an accepts guard, a type predicate that narrows the payload with no casting. When the guard passes, isDragOver() turns true and dropped fires with the typed data. The drop hands you the payload and you update your own signal, so your data stays the source of truth and nothing is mutated mid-drag.
type Card = { id: number; title: string };
const isCard = (d: unknown): d is Card =>
!!d && typeof d === 'object' && 'id' in d;
@Component({
imports: [DropTarget],
template: `
<section
mmDropTarget
#zone="mmDropTarget"
[accepts]="isCard"
[class.over]="zone.isDragOver()"
(dropped)="move($event.data)"
></section>
`,
})
class Column {
protected readonly isCard = isCard;
move(card: Card) { /* update your own signal */ }
}#More drop-target state
isDragOver() is true while an accepted source hovers the target at any depth. For nested targets, isInnermost() is true only on the deepest one under the pointer, so an outer region can stay quiet while its child lights up. dragOverData() gives you the hovering payload, already narrowed by accepts, so you can preview the drop before it happens. All three are derived from the same session, so reading them costs nothing until it changes.
protected readonly zone = dropTarget<Card>({ accepts: isCard });
// zone.isDragOver() any accepted source hovering, at any depth
// zone.isInnermost() true only on the deepest target under the pointer
// zone.dragOverData() the hovering Card (narrowed), else undefined Two more inputs shape native drops. sticky keeps the target active after the pointer leaves its bounds, and dropEffect ('move', 'copy', or 'link') sets the cursor the browser shows. Both are native-engine features and are compile-time-forbidden on the pointer engine.
<section
mmDropTarget
#zone="mmDropTarget"
[accepts]="isCard"
[sticky]="true"
dropEffect="copy"
></section>#Metadata channel
Alongside data, both a draggable and a drop target carry a typed meta payload. It is keyed by symbols, so it never collides with your own data or with tokens a plugin attaches, and it rides the drag separately from the payload. This is the seam the higher-level patterns build on: tag a source with the list it came from, then gate a drop on that tag in canDrop without touching the payload shape.
import { draggable, dropTarget } from '@mmstack/dnd';
const KIND = Symbol('kind');
draggable<Card, { [KIND]: 'todo' | 'done' }>({
data,
meta: () => ({ [KIND]: 'todo' }),
});
dropTarget<Card, void, { [KIND]: 'todo' | 'done' }>({
accepts: isCard,
// gate on the tag, not the payload:
canDrop: ({ source: { meta } }) => meta[KIND] === 'todo',
});#When to use this
Reach for the element layer when items move between free-form regions and there is no single ordered list to maintain, like a board with columns or a trash zone. For an ordered list where position matters, sortable lists handle the reordering and animation for you.