Repository: reppners/ngx-drag-drop
Branch: master
Commit: b888494da3a0
Files: 89
Total size: 147.4 KB
Directory structure:
gitextract_jzzxhiff/
├── .editorconfig
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── Bug_report.md
│ │ └── Feature_request.md
│ └── workflows/
│ ├── ci.yml
│ ├── deploy-pages.yml
│ └── publish.yml
├── .gitignore
├── .prettierignore
├── .prettierrc.json
├── .vscode/
│ ├── extensions.json
│ ├── launch.json
│ └── tasks.json
├── LICENSE
├── README.md
├── angular.json
├── package.json
├── pnpm-workspace.yaml
├── projects/
│ ├── demo/
│ │ ├── .browserslistrc
│ │ ├── src/
│ │ │ ├── app/
│ │ │ │ ├── app.component.html
│ │ │ │ ├── app.component.scss
│ │ │ │ ├── app.component.ts
│ │ │ │ ├── app.module.ts
│ │ │ │ ├── demo-link/
│ │ │ │ │ ├── demo-link.component.html
│ │ │ │ │ ├── demo-link.component.scss
│ │ │ │ │ └── demo-link.component.ts
│ │ │ │ ├── indirect-dnd-handle/
│ │ │ │ │ ├── indirect-dnd-handle.component.html
│ │ │ │ │ ├── indirect-dnd-handle.component.scss
│ │ │ │ │ └── indirect-dnd-handle.component.ts
│ │ │ │ ├── indirect-drag-image/
│ │ │ │ │ ├── indirect-drag-image.component.html
│ │ │ │ │ ├── indirect-drag-image.component.scss
│ │ │ │ │ └── indirect-drag-image.component.ts
│ │ │ │ ├── issue-195/
│ │ │ │ │ ├── issue-195.component.html
│ │ │ │ │ ├── issue-195.component.scss
│ │ │ │ │ └── issue-195.component.ts
│ │ │ │ ├── list/
│ │ │ │ │ ├── list.component.html
│ │ │ │ │ ├── list.component.scss
│ │ │ │ │ └── list.component.ts
│ │ │ │ ├── native/
│ │ │ │ │ ├── native.component.html
│ │ │ │ │ ├── native.component.scss
│ │ │ │ │ └── native.component.ts
│ │ │ │ ├── nested/
│ │ │ │ │ ├── nested.component.html
│ │ │ │ │ ├── nested.component.scss
│ │ │ │ │ └── nested.component.ts
│ │ │ │ ├── shadow-dom/
│ │ │ │ │ ├── shadow-dom.component.html
│ │ │ │ │ ├── shadow-dom.component.scss
│ │ │ │ │ └── shadow-dom.component.ts
│ │ │ │ ├── simple/
│ │ │ │ │ ├── simple.component.html
│ │ │ │ │ ├── simple.component.scss
│ │ │ │ │ └── simple.component.ts
│ │ │ │ ├── tree/
│ │ │ │ │ ├── tree.component.html
│ │ │ │ │ ├── tree.component.scss
│ │ │ │ │ └── tree.component.ts
│ │ │ │ └── typed/
│ │ │ │ ├── typed.component.html
│ │ │ │ ├── typed.component.scss
│ │ │ │ └── typed.component.ts
│ │ │ ├── assets/
│ │ │ │ └── .gitkeep
│ │ │ ├── dragdroptouch.d.ts
│ │ │ ├── environments/
│ │ │ │ ├── environment.prod.ts
│ │ │ │ └── environment.ts
│ │ │ ├── index.html
│ │ │ ├── main.ts
│ │ │ ├── polyfills.ts
│ │ │ └── styles.scss
│ │ ├── tsconfig.app.json
│ │ └── tsconfig.spec.json
│ └── dnd/
│ ├── .browserslistrc
│ ├── README.md
│ ├── ng-package.json
│ ├── package.json
│ ├── src/
│ │ ├── lib/
│ │ │ ├── dnd-draggable.directive.spec.ts
│ │ │ ├── dnd-draggable.directive.ts
│ │ │ ├── dnd-dropzone.directive.spec.ts
│ │ │ ├── dnd-dropzone.directive.ts
│ │ │ ├── dnd-handle.directive.spec.ts
│ │ │ ├── dnd-handle.directive.ts
│ │ │ ├── dnd-state.spec.ts
│ │ │ ├── dnd-state.ts
│ │ │ ├── dnd-types.ts
│ │ │ ├── dnd-utils.spec.ts
│ │ │ ├── dnd-utils.ts
│ │ │ └── dnd.module.ts
│ │ ├── public-api.ts
│ │ └── test-setup.ts
│ ├── tsconfig.lib.json
│ ├── tsconfig.lib.prod.json
│ └── tsconfig.spec.json
├── renovate.json
├── tsconfig.json
└── vitest.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false
================================================
FILE: .github/ISSUE_TEMPLATE/Bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
If possible please provide a StackBlitz based on this template
https://stackblitz.com/edit/ngx-drag-drop-issue-template
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/Feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
pull_request:
branches: [master]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: pnpm/action-setup@71c92474e7e4f5bca283fb17ef80fba9cdb2b4b1 # v6
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec prettier --check .
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: pnpm/action-setup@71c92474e7e4f5bca283fb17ef80fba9cdb2b4b1 # v6
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build:lib
- name: Run tests with coverage
run: |
echo '## Test Coverage' >> $GITHUB_STEP_SUMMARY
echo '' >> $GITHUB_STEP_SUMMARY
pnpm run test:coverage 2>&1 | tee /dev/stderr | sed -n '/^-.*|/,/^-.*|$/p' >> $GITHUB_STEP_SUMMARY
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: pnpm/action-setup@71c92474e7e4f5bca283fb17ef80fba9cdb2b4b1 # v6
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build:lib
- run: pnpm run build:docs
================================================
FILE: .github/workflows/deploy-pages.yml
================================================
name: Deploy Demo to GitHub Pages
on:
push:
branches: [master]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: pnpm/action-setup@71c92474e7e4f5bca283fb17ef80fba9cdb2b4b1 # v6
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build:lib
- run: pnpm run build:docs
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
path: docs
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5
================================================
FILE: .github/workflows/publish.yml
================================================
name: Publish to npm
on:
release:
types: [published]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: pnpm/action-setup@71c92474e7e4f5bca283fb17ef80fba9cdb2b4b1 # v6
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build:lib
- name: Run tests with coverage
run: |
echo '## Test Coverage' >> $GITHUB_STEP_SUMMARY
echo '' >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
pnpm run test:coverage 2>&1 | tee /dev/stderr | sed -n '/^-.*|/,/^-.*|$/p' >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
publish:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: pnpm/action-setup@71c92474e7e4f5bca283fb17ef80fba9cdb2b4b1 # v6
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: 24
cache: pnpm
registry-url: https://registry.npmjs.org/
- run: pnpm install --frozen-lockfile
- run: pnpm run build:lib
- run: pnpm publish ./dist/ngx-drag-drop --provenance --access public --no-git-checks
================================================
FILE: .gitignore
================================================
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/docs
/tmp
/out-tsc
# Only exists if Bazel was run
/bazel-out
/.ng_build
# dependencies
/node_modules
# profiling files
chrome-profiler-events*.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# misc
/.angular/cache
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
pnpm-debug.log
package-lock.json
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
node_modules
================================================
FILE: .prettierignore
================================================
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/docs
/tmp
/out-tsc
# Only exists if Bazel was run
/bazel-out
/.ng_build
/.github
# dependencies
/node_modules
# profiling files
chrome-profiler-events*.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# misc
/.angular/cache
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
pnpm-debug.log
pnpm-lock.yaml
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
node_modules
================================================
FILE: .prettierrc.json
================================================
{
"tabWidth": 2,
"useTabs": false,
"singleQuote": true,
"semi": true,
"bracketSpacing": true,
"arrowParens": "avoid",
"trailingComma": "es5",
"bracketSameLine": true,
"printWidth": 80
}
================================================
FILE: .vscode/extensions.json
================================================
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
================================================
FILE: .vscode/launch.json
================================================
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "pwa-chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
================================================
FILE: .vscode/tasks.json
================================================
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}
================================================
FILE: LICENSE
================================================
BSD 3-Clause License
Copyright (c) 2021, Stefan Steinhart
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================
FILE: README.md
================================================
[](https://www.npmjs.com/package/ngx-drag-drop)
[](https://www.npmjs.com/package/ngx-drag-drop)
[](https://www.npmjs.com/package/ngx-drag-drop)
[](https://github.com/ChristofFritz/ngx-drag-drop/issues)
[](https://twitter.com/intent/tweet?text=Angular%20drag%20and%20drop%20with%20ease:&url=https://github.com/ChristofFritz/ngx-drag-drop)
# NgxDragDrop
[_Demo_](https://christoffritz.github.io/ngx-drag-drop/) / [_StackBlitz Issue Template_](https://stackblitz.com/edit/ngx-drag-drop-issue-template)
```sh
npm install ngx-drag-drop
# or
pnpm add ngx-drag-drop
```
**Angular directives for declarative drag and drop using the HTML5 Drag-And-Drop API**
- sortable lists by using placeholder element (vertical and horizontal)
- nestable
- dropzones optionally support external/native draggables (img, txt, file)
- conditional drag/drop
- typed drag/drop
- utilize [EffectAllowed](https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/effectAllowed)
- custom CSS classes
- touch support by using a [polyfill](#touch-support)
- [AOT](https://angular.io/guide/aot-compiler) compatible
Port of [angular-drag-drop-lists](https://github.com/marceljuenemann/angular-drag-and-drop-lists) but without the lists :wink:
This has `dropzones` though :+1:
The idea is that the directive does not handle lists internally so the `dndDropzone` can be general purpose.
## Angular Version Compatibility
Starting with v13, the library major version matches the Angular major version.
| Angular | ngx-drag-drop |
| ------- | ------------- |
| 21.x | 21.x |
| 20.x | 20.x |
| 19.x | 19.x |
| 18.x | 18.x |
| 17.x | 17.x |
| 16.x | 16.x |
| 15.x | 15.x |
| 14.x | 14.x |
| 13.x | 13.x |
For older Angular versions (v4–v12), use ngx-drag-drop v2.x.
## Usage
`app.component.html`
```HTML
HANDLE
draggable ({{draggable.effectAllowed}})
DISABLED
DRAG_IMAGE
```
`app.component`
```JS
import { Component } from '@angular/core';
import { DndDropEvent } from 'ngx-drag-drop';
@Component()
export class AppComponent {
draggable = {
// note that data is handled with JSON.stringify/JSON.parse
// only set simple data or POJO's as methods will be lost
data: "myDragData",
effectAllowed: "all",
disable: false,
handle: false
};
onDragStart(event:DragEvent) {
console.log("drag started", JSON.stringify(event, null, 2));
}
onDragEnd(event:DragEvent) {
console.log("drag ended", JSON.stringify(event, null, 2));
}
onDraggableCopied(event:DragEvent) {
console.log("draggable copied", JSON.stringify(event, null, 2));
}
onDraggableLinked(event:DragEvent) {
console.log("draggable linked", JSON.stringify(event, null, 2));
}
onDraggableMoved(event:DragEvent) {
console.log("draggable moved", JSON.stringify(event, null, 2));
}
onDragCanceled(event:DragEvent) {
console.log("drag cancelled", JSON.stringify(event, null, 2));
}
onDragover(event:DragEvent) {
console.log("dragover", JSON.stringify(event, null, 2));
}
onDrop(event:DndDropEvent) {
console.log("dropped", JSON.stringify(event, null, 2));
}
}
```
`app.module`
```JS
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { DndModule } from 'ngx-drag-drop';
import { AppComponent } from './app.component';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
DndModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {
}
```
## API
```TS
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/dropEffect
export type DropEffect = "move" | "copy" | "link" | "none";
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/effectAllowed
export type EffectAllowed = DropEffect | "copyMove" | "copyLink" | "linkMove" | "all";
```
```TS
export type DndDragImageOffsetFunction = ( event:DragEvent, dragImage:Element ) => { x:number, y:number };
@Directive( {
selector: "[dndDraggable]"
} )
export declare class DndDraggableDirective {
// the data attached to the drag
dndDraggable: any;
// the allowed drop effect
dndEffectAllowed: EffectAllowed;
// optionally set the type of dragged data to restrict dropping on compatible dropzones
dndType?: string;
// conditionally disable the draggability
dndDisableIf: boolean;
dndDisableDragIf: boolean;
// set a custom class that is applied while dragging
dndDraggingClass: string = "dndDragging";
// set a custom class that is applied to only the src element while dragging
dndDraggingSourceClass: string = "dndDraggingSource";
// set the class that is applied when draggable is disabled by [dndDisableIf]
dndDraggableDisabledClass = "dndDraggableDisabled";
// enables to set a function for calculating custom dragimage offset
dndDragImageOffsetFunction:DndDragImageOffsetFunction = calculateDragImageOffset;
// emits on drag start
readonly dndStart: EventEmitter;
// emits on drag
readonly dndDrag: EventEmitter;
// emits on drag end
readonly dndEnd: EventEmitter;
// emits when the dragged item has been dropped with effect "move"
readonly dndMoved: EventEmitter;
// emits when the dragged item has been dropped with effect "copy"
readonly dndCopied: EventEmitter;
// emits when the dragged item has been dropped with effect "link"
readonly dndLinked: EventEmitter;
// emits when the drag is canceled
readonly dndCanceled: EventEmitter;
}
```
```TS
export interface DndDropEvent {
// the original drag event
event: DragEvent;
// the actual drop effect
dropEffect: DropEffect;
// true if the drag did not origin from a [dndDraggable]
isExternal:boolean;
// the data set on the [dndDraggable] that started the drag
// for external drags use the event property which contains the original drop event as this will be undefined
data?: any;
// the index where the draggable was dropped in a dropzone
// set only when using a placeholder
index?: number;
// if the dndType input on dndDraggable was set
// it will be transported here
type?: any;
}
@Directive( {
selector: "[dndDropzone]"
} )
export declare class DndDropzoneDirective {
// optionally restrict the allowed types
dndDropzone?: string[];
// set the allowed drop effect
dndEffectAllowed: EffectAllowed;
// conditionally disable the dropzone
dndDisableIf: boolean;
dndDisableDropIf: boolean;
// if draggables that are not [dndDraggable] are allowed to be dropped
// set to true if dragged text, images or files should be handled
dndAllowExternal: boolean;
// if its a horizontal list this influences how the placeholder position
// is calculated
dndHorizontal: boolean;
// set the class applied to the dropzone
// when a draggable is dragged over it
dndDragoverClass: string = "dndDragover";
// set the class applied to the dropzone
// when the dropzone is disabled by [dndDisableIf]
dndDropzoneDisabledClass = "dndDropzoneDisabled";
// emits when a draggable is dragged over the dropzone
readonly dndDragover: EventEmitter;
// emits on successful drop
readonly dndDrop: EventEmitter;
}
```
## Touch support (experimental)
This library uses the native HTML5 Drag and Drop API, which is **not supported on most mobile browsers** (including iOS Safari). Touch support requires a polyfill that translates touch events into drag events. This approach has known limitations and the experience may not be reliable on all devices.
To enable basic touch support, install the `@dragdroptouch/drag-drop-touch` polyfill:
```JS
import { enableDragDropTouch } from "@dragdroptouch/drag-drop-touch";
enableDragDropTouch();
```
For more info on the polyfill check it out on GitHub
https://github.com/drag-drop-touch-js/dragdroptouch
## Known issues
### Firefox
- Beware that Firefox does not support dragging on `` elements.
- `` and `` won't work.
- See https://bugzilla.mozilla.org/show_bug.cgi?id=568313
## Why?
HTML Drag-And-Drop API implementations are not behaving the same way across browsers.
The directives contained in this module enable declarative drag and drop that "just works" across browsers in a consistent way.
Credits go to the author and contributors of [angular-drag-drop-lists](https://github.com/marceljuenemann/angular-drag-and-drop-lists).
## Maintenance
This project was generated with [Angular CLI](https://github.com/angular/angular-cli).
See https://angular.io/guide/creating-libraries
#### Edit Library
- run `pnpm run watch:lib` for hacking on library
#### Release Library
- assure correct version is set in `projects/dnd/package.json`
- build library with `pnpm run build:lib`
- publish library with `pnpm run publish:stable` (use `pnpm run publish:next` for pre-releases)
#### Edit Docs
- initially and on lib changes run `pnpm run build:lib` to current version of lib available to the demo
- run `pnpm run start:docs`
#### Release Docs
The demo site is automatically deployed to GitHub Pages on every push to `master` via GitHub Actions.
---
Made with :heart: &
& :coffee:
================================================
FILE: angular.json
================================================
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"cli": {
"analytics": false
},
"version": 1,
"newProjectRoot": "projects",
"projects": {
"dnd": {
"projectType": "library",
"root": "projects/dnd",
"sourceRoot": "projects/dnd/src",
"prefix": "dnd",
"architect": {
"build": {
"builder": "@angular/build:ng-packagr",
"options": {
"project": "projects/dnd/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/dnd/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "projects/dnd/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
}
}
},
"demo": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
},
"@schematics/angular:application": {
"strict": true
}
},
"root": "projects/demo",
"sourceRoot": "projects/demo/src",
"prefix": "dnd",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "docs",
"browser": ""
},
"index": "projects/demo/src/index.html",
"polyfills": ["projects/demo/src/polyfills.ts"],
"tsConfig": "projects/demo/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"projects/demo/src/favicon.ico",
"projects/demo/src/assets"
],
"styles": [
"./node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css",
"projects/demo/src/styles.scss"
],
"scripts": [],
"browser": "projects/demo/src/main.ts"
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"fileReplacements": [
{
"replace": "projects/demo/src/environments/environment.ts",
"with": "projects/demo/src/environments/environment.prod.ts"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "demo:build:production"
},
"development": {
"buildTarget": "demo:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n",
"options": {
"buildTarget": "demo:build"
}
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}
================================================
FILE: package.json
================================================
{
"name": "ngx-drag-drop-workspace",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "pnpm run build:lib && pnpm run start:docs",
"start:docs": "ng serve demo",
"build:lib": "ng build dnd",
"watch:lib": "ng build dnd --watch --configuration development",
"build:docs": "ng build demo --configuration production --base-href /ngx-drag-drop/ && cp docs/index.html docs/404.html",
"watch:docs": "ng build demo --watch --configuration development",
"publish:stable": "pnpm publish ./dist/ngx-drag-drop",
"publish:next": "pnpm publish ./dist/ngx-drag-drop --tag next",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"prettier": "pnpm exec prettier --write ."
},
"private": true,
"packageManager": "pnpm@10.33.0",
"dependencies": {
"@angular/animations": "^21.2.6",
"@angular/cdk": "^21.2.4",
"@angular/common": "^21.2.6",
"@angular/compiler": "^21.2.6",
"@angular/core": "^21.2.6",
"@angular/forms": "^21.2.6",
"@angular/material": "^21.2.4",
"@angular/platform-browser": "^21.2.6",
"@angular/platform-browser-dynamic": "^21.2.6",
"@angular/router": "^21.2.6",
"@dragdroptouch/drag-drop-touch": "^2.0.3",
"bootstrap": "^5.3.8",
"rxjs": "~7.8.2",
"tslib": "^2.3.0",
"zone.js": "~0.16.1"
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "2.4.8",
"@angular/build": "21.2.7",
"@angular/cli": "21.2.7",
"@angular/compiler-cli": "21.2.9",
"@types/node": "24.12.2",
"@vitest/coverage-v8": "4.1.4",
"jsdom": "29.0.2",
"ng-packagr": "21.2.2",
"prettier": "3.8.3",
"typescript": "6.0.2",
"vitest": "4.1.4"
}
}
================================================
FILE: pnpm-workspace.yaml
================================================
allowBuilds:
'@parcel/watcher': true
esbuild: true
lmdb: true
msgpackr-extract: true
================================================
FILE: projects/demo/.browserslistrc
================================================
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# For the full list of supported browsers by the Angular framework, please see:
# https://angular.io/guide/browser-support
# You can see what browsers were selected by your queries by running:
# npx browserslist
last 1 Chrome version
last 1 Firefox version
last 2 Edge major versions
last 2 Safari major versions
last 2 iOS major versions
Firefox ESR
================================================
FILE: projects/demo/src/app/app.component.html
================================================
{{ title }}
Issue Demos
@for (demo of issueDemos; track demo.issue) {
{{ demo.label }}
}
@if (activeTab$ | async; as activeTab) {
@for (tab of tabs; track tab) {
{{ tab }}
}
}
================================================
FILE: projects/demo/src/app/app.component.scss
================================================
:host {
display: flex;
flex: 1 1 auto;
flex-direction: column;
max-height: 100vh;
max-width: 100vw;
height: 100%;
width: 100%;
}
.container-padding {
padding: 8px;
}
.scrollable {
overflow: auto;
-webkit-overflow-scrolling: touch;
}
/* workaround for flex tab content */
/* TODO(mdc-migration): The following rule targets internal classes of tabs that may no longer apply for the MDC version. */
mat-tab-group ::ng-deep .mat-tab-body-wrapper {
height: 100%;
flex: 1 1 auto;
}
/* TODO(mdc-migration): The following rule targets internal classes of tabs that may no longer apply for the MDC version. */
mat-tab-group ::ng-deep .mat-tab-body-wrapper .mat-tab-body-content {
display: flex;
height: 100%;
flex: 1 1 auto;
}
================================================
FILE: projects/demo/src/app/app.component.ts
================================================
import { Component } from '@angular/core';
import { MatIconRegistry } from '@angular/material/icon';
import { DomSanitizer } from '@angular/platform-browser';
import { ActivatedRoute, ActivationEnd, Router } from '@angular/router';
import { filter, map, Observable, shareReplay, startWith } from 'rxjs';
const TABS: string[] = [
'simple',
'list',
'nested',
'tree',
'native',
'typed',
'shadow-dom',
];
const ISSUE_DEMOS: { issue: number; label: string }[] = [
{ issue: 195, label: '#195 — dropEffect ignores dropzone effectAllowed' },
];
const DEFAULT_TAB = TABS[0];
@Component({
selector: 'dnd-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
standalone: false,
})
export class AppComponent {
readonly title = 'NgxDragDrop Demo';
readonly tabs: string[] = TABS;
readonly issueDemos = ISSUE_DEMOS;
readonly activeTab$: Observable;
constructor(
sanitizer: DomSanitizer,
iconRegistry: MatIconRegistry,
activatedRoute: ActivatedRoute,
private router: Router
) {
iconRegistry.addSvgIcon(
'github',
sanitizer.bypassSecurityTrustResourceUrl('assets/github.svg')
);
this.activeTab$ = this.router.events.pipe(
filter(event => event instanceof ActivationEnd),
map(event => {
const activationEnd = event as ActivationEnd;
if (!!activationEnd?.snapshot?.url?.length) {
return activationEnd?.snapshot?.url[0]?.path ?? DEFAULT_TAB;
}
return DEFAULT_TAB;
}),
startWith(DEFAULT_TAB),
shareReplay(1)
);
}
onTabLinkClick(tab: string) {
this.router.navigate([tab]);
}
onIssueDemoClick(issue: number) {
this.router.navigate(['issue', issue]);
}
}
================================================
FILE: projects/demo/src/app/app.module.ts
================================================
import {
provideHttpClient,
withInterceptorsFromDi,
} from '@angular/common/http';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatLineModule } from '@angular/material/core';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatMenuModule } from '@angular/material/menu';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs';
import { MatToolbarModule } from '@angular/material/toolbar';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { RouterModule, Routes } from '@angular/router';
import { DndModule } from 'ngx-drag-drop';
import { AppComponent } from './app.component';
import { DemoLinkComponent } from './demo-link/demo-link.component';
const routes: Routes = [
{
path: 'simple',
loadComponent: () => import('./simple/simple.component'),
},
{
path: 'list',
loadComponent: () => import('./list/list.component'),
},
{
path: 'nested',
loadComponent: () => import('./nested/nested.component'),
},
{
path: 'tree',
loadComponent: () => import('./tree/tree.component'),
},
{
path: 'native',
loadComponent: () => import('./native/native.component'),
},
{
path: 'typed',
loadComponent: () => import('./typed/typed.component'),
},
{
path: 'shadow-dom',
loadComponent: () => import('./shadow-dom/shadow-dom.component'),
},
{
path: 'issue/195',
loadComponent: () => import('./issue-195/issue-195.component'),
},
{
path: '**',
pathMatch: 'full',
redirectTo: 'simple',
},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
@NgModule({
declarations: [AppComponent],
bootstrap: [AppComponent],
imports: [
BrowserModule,
BrowserAnimationsModule,
DndModule,
MatButtonModule,
MatInputModule,
MatToolbarModule,
MatCardModule,
MatSnackBarModule,
MatSlideToggleModule,
MatIconModule,
MatMenuModule,
MatTabsModule,
AppRoutingModule,
MatLineModule,
MatListModule,
DemoLinkComponent,
],
providers: [provideHttpClient(withInterceptorsFromDi())],
})
export class AppModule {}
================================================
FILE: projects/demo/src/app/demo-link/demo-link.component.html
================================================
go to sources
================================================
FILE: projects/demo/src/app/demo-link/demo-link.component.scss
================================================
:host {
margin: 8px 0;
}
================================================
FILE: projects/demo/src/app/demo-link/demo-link.component.ts
================================================
import { Component, Input } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'dnd-demo-link',
templateUrl: './demo-link.component.html',
styleUrls: ['./demo-link.component.scss'],
standalone: true,
imports: [MatButtonModule],
})
export class DemoLinkComponent {
@Input()
name: string | null = null;
public get url(): string {
return `https://github.com/ChristofFritz/ngx-drag-drop/tree/master/projects/demo/src/app/${this.name}`;
}
}
================================================
FILE: projects/demo/src/app/indirect-dnd-handle/indirect-dnd-handle.component.html
================================================
================================================
FILE: projects/demo/src/app/indirect-dnd-handle/indirect-dnd-handle.component.scss
================================================
================================================
FILE: projects/demo/src/app/indirect-dnd-handle/indirect-dnd-handle.component.ts
================================================
import { Component, HostBinding } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { DndHandleDirective } from 'ngx-drag-drop';
@Component({
selector: 'dnd-indirect-handle',
templateUrl: './indirect-dnd-handle.component.html',
styleUrls: ['./indirect-dnd-handle.component.scss'],
standalone: true,
imports: [MatIconModule, DndHandleDirective],
})
export class IndirectDndHandleComponent {
@HostBinding('class.drag-handle') get dragHandle() {
return true;
}
}
================================================
FILE: projects/demo/src/app/indirect-drag-image/indirect-drag-image.component.html
================================================
================================================
FILE: projects/demo/src/app/indirect-drag-image/indirect-drag-image.component.scss
================================================
================================================
FILE: projects/demo/src/app/indirect-drag-image/indirect-drag-image.component.ts
================================================
import { Component } from '@angular/core';
import { DndDragImageRefDirective } from 'ngx-drag-drop';
@Component({
selector: 'dnd-indirect-drag-image',
templateUrl: './indirect-drag-image.component.html',
styleUrls: ['./indirect-drag-image.component.scss'],
standalone: true,
imports: [DndDragImageRefDirective],
})
export class IndirectDragImageComponent {}
================================================
FILE: projects/demo/src/app/issue-195/issue-195.component.html
================================================
Issue #195: dropEffect ignores dropzone's dndEffectAllowed
Draggable has dndEffectAllowed="all". Dropzone has
dndEffectAllowed="link". On drop, the
dropEffect should be "link", not
"move".
Drag me (effectAllowed: all)
Drop here (effectAllowed: link)
@if (lastDropEvent) {
Dropzone received dropEffect: {{ lastDropEvent.dropEffect }}
{{
lastDropEvent.dropEffect === 'link'
? 'PASS'
: 'FAIL — expected "link"'
}}
Draggable fired effect: {{ dragEffect }}
{{ dragEffect === 'link' ? 'PASS' : 'FAIL — expected "link"' }}
}
================================================
FILE: projects/demo/src/app/issue-195/issue-195.component.scss
================================================
.demo {
display: flex;
gap: 32px;
padding: 16px;
align-items: flex-start;
}
.draggable {
padding: 16px 24px;
background: white;
border: 2px solid #3f51b5;
border-radius: 4px;
cursor: move;
}
.dropzone {
padding: 24px;
min-height: 80px;
min-width: 200px;
background: #f5f5f5;
border: 2px dashed #999;
border-radius: 4px;
}
.placeholder {
padding: 16px;
border: 2px dashed orangered;
border-radius: 4px;
}
.result {
margin: 16px;
padding: 12px 16px;
background: #fafafa;
border-radius: 4px;
display: flex;
gap: 16px;
align-items: center;
}
.pass {
color: green;
font-weight: bold;
}
.fail {
color: red;
font-weight: bold;
}
================================================
FILE: projects/demo/src/app/issue-195/issue-195.component.ts
================================================
import { JsonPipe } from '@angular/common';
import { Component } from '@angular/core';
import {
DndDraggableDirective,
DndDropEvent,
DndDropzoneDirective,
DndPlaceholderRefDirective,
} from 'ngx-drag-drop';
@Component({
selector: 'dnd-issue-195',
templateUrl: './issue-195.component.html',
styleUrls: ['./issue-195.component.scss'],
standalone: true,
imports: [
DndDraggableDirective,
DndDropzoneDirective,
DndPlaceholderRefDirective,
JsonPipe,
],
})
export default class Issue195Component {
lastDropEvent: DndDropEvent | null = null;
dragEffect: string = '';
onDrop(event: DndDropEvent) {
this.lastDropEvent = event;
}
}
================================================
FILE: projects/demo/src/app/list/list.component.html
================================================
dndEffectAllowed="copyMove"
@for (item of draggableListLeft; track item) {
@if (item.handle) {
}
{{ item.content }}
effectAllowed: {{ item.effectAllowed }}
}
dndEffectAllowed="copyMove"
@for (item of draggableListRight; track item) {
@if (item.handle) {
}
{{ item.content }}
effectAllowed: {{ item.effectAllowed }}
}
trash (dndEffectAllowed="move")
================================================
FILE: projects/demo/src/app/list/list.component.scss
================================================
:host {
display: block;
}
pre {
text-align: center;
padding: 8px;
}
.dndList {
transition: all 300ms ease;
padding: 5px;
&:not(.horizontal).dndDragover {
padding-top: 12px;
padding-bottom: 12px;
}
&.horizontal {
&.dndDragover {
padding-left: 12px;
padding-right: 12px;
padding-bottom: 12px;
}
.mat-mdc-list-item {
max-width: 120px;
margin: 2px;
}
}
}
.dndDragging {
border: 1px solid green;
}
.dndDraggingSource {
display: none;
}
.dndPlaceholder {
min-height: 72px;
}
================================================
FILE: projects/demo/src/app/list/list.component.ts
================================================
import { Component } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatSnackBar } from '@angular/material/snack-bar';
import {
DndDraggableDirective,
DndDropEvent,
DndDropzoneDirective,
DndHandleDirective,
DndPlaceholderRefDirective,
DropEffect,
EffectAllowed,
} from 'ngx-drag-drop';
interface DraggableItem {
content: string;
effectAllowed: EffectAllowed;
disable: boolean;
handle: boolean;
}
interface DropzoneLayout {
container: string;
list: string;
dndHorizontal: boolean;
}
@Component({
selector: 'dnd-list',
templateUrl: './list.component.html',
styleUrls: ['./list.component.scss'],
standalone: true,
imports: [
MatListModule,
DndDropzoneDirective,
DndPlaceholderRefDirective,
DndDraggableDirective,
DndHandleDirective,
MatIconModule,
],
})
export default class ListComponent {
draggableListLeft: DraggableItem[] = [
{
content: 'Left',
effectAllowed: 'move',
disable: false,
handle: false,
},
{
content: 'Lefter',
effectAllowed: 'move',
disable: false,
handle: false,
},
{
content: 'Leftest',
effectAllowed: 'copyMove',
disable: false,
handle: false,
},
{
content: 'Lefty',
effectAllowed: 'move',
disable: false,
handle: true,
},
{
content: 'Left',
effectAllowed: 'move',
disable: false,
handle: false,
},
{
content: 'Lefter',
effectAllowed: 'move',
disable: false,
handle: false,
},
{
content: 'Leftest',
effectAllowed: 'copyMove',
disable: false,
handle: false,
},
{
content: 'Lefty',
effectAllowed: 'move',
disable: false,
handle: true,
},
{
content: 'Left',
effectAllowed: 'move',
disable: false,
handle: false,
},
{
content: 'Lefter',
effectAllowed: 'move',
disable: false,
handle: false,
},
{
content: 'Leftest',
effectAllowed: 'copyMove',
disable: false,
handle: false,
},
{
content: 'Lefty',
effectAllowed: 'move',
disable: false,
handle: true,
},
];
draggableListRight: DraggableItem[] = [
{
content: 'I was originally right',
effectAllowed: 'move',
disable: false,
handle: false,
},
];
horizontalLayoutActive: boolean = false;
private currentDraggableEvent?: DragEvent;
private currentDragEffectMsg?: string;
private readonly verticalLayout: DropzoneLayout = {
container: 'row',
list: 'column',
dndHorizontal: false,
};
layout: DropzoneLayout = this.verticalLayout;
private readonly horizontalLayout: DropzoneLayout = {
container: 'row',
list: 'row wrap',
dndHorizontal: true,
};
constructor(private snackBarService: MatSnackBar) {}
setHorizontalLayout(horizontalLayoutActive: boolean) {
this.layout = horizontalLayoutActive
? this.horizontalLayout
: this.verticalLayout;
}
onDragStart(event: DragEvent) {
this.currentDragEffectMsg = '';
this.currentDraggableEvent = event;
this.snackBarService.dismiss();
this.snackBarService.open('Drag started!', undefined, { duration: 2000 });
}
onDragged(item: any, list: any[], effect: DropEffect) {
this.currentDragEffectMsg = `Drag ended with effect "${effect}"!`;
if (effect === 'move') {
const index = list.indexOf(item);
list.splice(index, 1);
}
}
onDragEnd(event: DragEvent) {
this.currentDraggableEvent = event;
this.snackBarService.dismiss();
this.snackBarService.open(
this.currentDragEffectMsg || `Drag ended!`,
undefined,
{ duration: 2000 }
);
}
onDrop(event: DndDropEvent, list?: any[]) {
if (list && (event.dropEffect === 'copy' || event.dropEffect === 'move')) {
let index = event.index;
if (typeof index === 'undefined') {
index = list.length;
}
list.splice(index, 0, event.data);
}
}
}
================================================
FILE: projects/demo/src/app/native/native.component.html
================================================
Draggable stuff
Drag the image or a text selection onto the dropzone and see what
happens
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam
nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam
erat, sed diam voluptua. At vero eos et accusam et justo duo dolores
et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est
Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur
sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore
et dolore magna aliquyam erat, sed diam voluptua. At vero eos et
accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren,
no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum
dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod
tempor invidunt ut labore et dolore magna aliquyam erat, sed diam
voluptua. At vero eos et accusam et justo duo dolores et ea rebum.
Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum
dolor sit amet.
Dropzone
@if (lastDropEvent) {
Event: {{ lastDropEvent | json }}
}
@if (lastDropTypes && lastDropTypes.length) {
Types: {{ lastDropTypes | json }}
}
@if (lastDropFiles && lastDropFiles.length) {
Files: {{ lastDropFiles | json }}
}
@if (lastDropItems && lastDropItems.length) {
Items: {{ lastDropItems | json }}
}
================================================
FILE: projects/demo/src/app/native/native.component.scss
================================================
:host {
display: block;
box-sizing: border-box;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
================================================
FILE: projects/demo/src/app/native/native.component.ts
================================================
import { JsonPipe } from '@angular/common';
import { Component } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatSnackBar } from '@angular/material/snack-bar';
import { DndDropEvent, DndDropzoneDirective } from 'ngx-drag-drop';
@Component({
selector: 'dnd-native',
templateUrl: './native.component.html',
styleUrls: ['./native.component.scss'],
standalone: true,
imports: [MatCardModule, DndDropzoneDirective, JsonPipe],
})
export default class NativeComponent {
public lastDropEvent: DndDropEvent | null = null;
public lastDropTypes?: ReadonlyArray;
public lastDropFiles?: object[];
public lastDropItems?: object[];
constructor(private snackBarService: MatSnackBar) {}
onDrop(event: DndDropEvent) {
this.snackBarService.dismiss();
this.snackBarService.open(`Something dropped O.O`, undefined, {
duration: 2000,
});
this.lastDropEvent = event;
this.lastDropTypes = this.lastDropEvent.event.dataTransfer?.types;
this.lastDropFiles = [];
this.lastDropItems = [];
if (this.lastDropEvent.event.dataTransfer?.files) {
for (
let i: number = 0;
i < this.lastDropEvent.event.dataTransfer?.files.length;
i++
) {
const file = this.lastDropEvent.event.dataTransfer?.files[i];
this.lastDropFiles.push({
lastModifiedDate: file.lastModified,
name: file.name,
type: file.type,
size: file.size,
});
}
for (
let i: number = 0;
i < this.lastDropEvent.event.dataTransfer.items.length;
i++
) {
const item = this.lastDropEvent.event.dataTransfer.items[i];
this.lastDropItems.push({
type: item.type,
kind: item.kind,
data: this.lastDropEvent.event.dataTransfer.getData(item.type),
});
}
}
}
}
================================================
FILE: projects/demo/src/app/nested/nested.component.html
================================================
@for (item of list; track item) {
@if (item.handle) {
drag_handle
}
{{ item.content }}
@if (!!item.customDragImage) {
MY_CUSTOM_DRAG_IMAGE
}
@if (item.children) {
}
}
================================================
FILE: projects/demo/src/app/nested/nested.component.scss
================================================
:host {
display: block;
box-sizing: border-box;
}
mat-card {
transition:
transform 200ms,
opacity 200ms;
}
mat-card-header {
border-bottom: 2px solid rgba(0, 0, 0, 0.04);
align-items: center;
min-height: 46px;
}
.dndDraggingSource {
opacity: 0.5;
transform: scale(0.98);
}
.dndPlaceholder {
background: #fff;
min-height: 46px;
min-width: 46px;
}
[dnddropzone] {
background: #fafafa;
min-height: 60px;
display: flex;
outline: 2px solid rgba(0, 0, 0, 0.04);
}
================================================
FILE: projects/demo/src/app/nested/nested.component.ts
================================================
import { NgTemplateOutlet } from '@angular/common';
import { Component } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import {
DndDraggableDirective,
DndDragImageRefDirective,
DndDropEvent,
DndDropzoneDirective,
DndHandleDirective,
DndPlaceholderRefDirective,
DropEffect,
} from 'ngx-drag-drop';
interface NestableListItem {
content: string;
disable?: boolean;
handle?: boolean;
customDragImage?: boolean;
children?: NestableListItem[];
}
@Component({
selector: 'dnd-nested',
templateUrl: './nested.component.html',
styleUrls: ['./nested.component.scss'],
standalone: true,
imports: [
MatCardModule,
DndPlaceholderRefDirective,
DndDraggableDirective,
MatIconModule,
DndHandleDirective,
DndDragImageRefDirective,
DndDropzoneDirective,
NgTemplateOutlet,
],
})
export default class NestedComponent {
nestableList: NestableListItem[] = [
{
content: 'Got something nested',
children: [
{
content: 'Nested',
customDragImage: true,
children: [],
},
],
},
{
content: 'No nested dropping here',
},
{
content: 'Got more than one',
children: [
{
content: 'Nested 1',
handle: true,
children: [],
},
{
content: 'Nested 2',
children: [],
},
{
content: 'Nested 3',
children: [],
},
],
},
{
content: "Drop something, I'll catch!",
children: [],
},
];
private currentDraggableEvent?: DragEvent;
private currentDragEffectMsg?: string;
constructor(private snackBarService: MatSnackBar) {}
onDragStart(event: DragEvent) {
this.currentDragEffectMsg = '';
this.currentDraggableEvent = event;
this.snackBarService.dismiss();
this.snackBarService.open('Drag started!', undefined, { duration: 2000 });
}
onDragged(item: any, list: any[], effect: DropEffect) {
this.currentDragEffectMsg = `Drag ended with effect "${effect}"!`;
if (effect === 'move') {
const index = list.indexOf(item);
list.splice(index, 1);
}
}
onDragEnd(event: DragEvent) {
this.currentDraggableEvent = event;
this.snackBarService.dismiss();
this.snackBarService.open(
this.currentDragEffectMsg || `Drag ended!`,
undefined,
{ duration: 2000 }
);
}
onDrop(event: DndDropEvent, list?: any[]) {
if (list && (event.dropEffect === 'copy' || event.dropEffect === 'move')) {
let index = event.index;
if (typeof index === 'undefined') {
index = list.length;
}
list.splice(index, 0, event.data);
}
}
}
================================================
FILE: projects/demo/src/app/shadow-dom/shadow-dom.component.html
================================================
@for (item of list; track item) {
}
================================================
FILE: projects/demo/src/app/shadow-dom/shadow-dom.component.scss
================================================
:host {
display: block;
padding: 16px;
}
.root-dropzone,
.nested-dropzone {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
min-height: 40px;
background: #f5f5f5;
border-radius: 4px;
}
.nested-dropzone {
margin-top: 8px;
background: #ede7f6;
}
.drag-item {
padding: 12px 16px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
cursor: move;
}
.drag-item-header {
font-weight: 500;
}
.placeholder {
padding: 12px 16px;
border: 2px dashed orangered;
border-radius: 4px;
background: rgba(255, 69, 0, 0.05);
}
.dndDragover {
outline: 2px solid #3f51b5;
}
================================================
FILE: projects/demo/src/app/shadow-dom/shadow-dom.component.ts
================================================
import { NgTemplateOutlet } from '@angular/common';
import { Component, ViewEncapsulation } from '@angular/core';
import {
DndDraggableDirective,
DndDropEvent,
DndDropzoneDirective,
DndPlaceholderRefDirective,
DropEffect,
} from 'ngx-drag-drop';
interface NestableListItem {
content: string;
children?: NestableListItem[];
}
@Component({
selector: 'dnd-shadow-dom',
templateUrl: './shadow-dom.component.html',
styleUrls: ['./shadow-dom.component.scss'],
standalone: true,
encapsulation: ViewEncapsulation.ShadowDom,
imports: [
DndDropzoneDirective,
DndPlaceholderRefDirective,
DndDraggableDirective,
NgTemplateOutlet,
],
})
export default class ShadowDomComponent {
nestableList: NestableListItem[] = [
{
content: 'Parent A',
children: [
{ content: 'Child A.1', children: [] },
{ content: 'Child A.2', children: [] },
],
},
{
content: 'Parent B (no children)',
},
{
content: 'Parent C',
children: [
{ content: 'Child C.1', children: [] },
{ content: 'Child C.2', children: [] },
{ content: 'Child C.3', children: [] },
],
},
{
content: 'Parent D (empty)',
children: [],
},
];
onDragged(
item: NestableListItem,
list: NestableListItem[],
effect: DropEffect
) {
if (effect === 'move') {
const index = list.indexOf(item);
list.splice(index, 1);
}
}
onDrop(event: DndDropEvent, list?: NestableListItem[]) {
if (list && (event.dropEffect === 'copy' || event.dropEffect === 'move')) {
let index = event.index;
if (typeof index === 'undefined') {
index = list.length;
}
list.splice(index, 0, event.data);
}
}
}
================================================
FILE: projects/demo/src/app/simple/simple.component.html
================================================
@for (draggable of draggables; track draggable) {
@if (draggable.handle) {
}
draggable ({{ draggable.effectAllowed }})
only with handle
DISABLED
}
@if (draggableWithDragImage.handle) {
}
draggable ({{ draggableWithDragImage.effectAllowed }})
only with handle
DISABLED
MY_CUSTOM_DRAG_IMAGE
draggable ({{ draggableWithDragImage.effectAllowed }})
I'm the drag image but pssst
draggable ({{ draggableWithDragImage.effectAllowed }})
Dropzone
Enabled
@if (lastDropEvent) {
{{ lastDropEvent | json }}
}
================================================
FILE: projects/demo/src/app/simple/simple.component.scss
================================================
:host {
display: block;
height: 100%;
}
.my-dropzone {
transition: all 300ms ease;
}
.custom-drag-over {
background-color: rgba(0, 0, 0, 0.06);
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
================================================
FILE: projects/demo/src/app/simple/simple.component.ts
================================================
import { JsonPipe } from '@angular/common';
import { Component } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSnackBar } from '@angular/material/snack-bar';
import {
DndDraggableDirective,
DndDragImageOffsetFunction,
DndDragImageRefDirective,
DndDropEvent,
DndDropzoneDirective,
DndHandleDirective,
EffectAllowed,
} from 'ngx-drag-drop';
import { IndirectDndHandleComponent } from '../indirect-dnd-handle/indirect-dnd-handle.component';
import { IndirectDragImageComponent } from '../indirect-drag-image/indirect-drag-image.component';
interface DraggableItem {
content: string;
effectAllowed: EffectAllowed;
disable: boolean;
handle: boolean;
}
@Component({
selector: 'dnd-simple',
templateUrl: './simple.component.html',
styleUrls: ['./simple.component.scss'],
standalone: true,
imports: [
MatCardModule,
DndDraggableDirective,
DndHandleDirective,
MatIconModule,
DndDragImageRefDirective,
IndirectDndHandleComponent,
IndirectDragImageComponent,
MatSlideToggleModule,
DndDropzoneDirective,
JsonPipe,
],
})
export default class SimpleComponent {
draggables: DraggableItem[] = [
{
content: 'testdata',
effectAllowed: 'copy',
disable: false,
handle: false,
},
{
content: 'testdata2',
effectAllowed: 'move',
disable: false,
handle: false,
},
{
content: 'testdata3',
effectAllowed: 'link',
disable: false,
handle: false,
},
{
content: 'testdata4',
effectAllowed: 'copy',
disable: true,
handle: false,
},
{
content: 'testdata5',
effectAllowed: 'copy',
disable: false,
handle: true,
},
];
draggableWithDragImage: DraggableItem = {
content: 'testdata6',
effectAllowed: 'copy',
disable: false,
handle: true,
};
public dropzoneEnabled: boolean = true;
public lastDropEvent: DndDropEvent | null = null;
private currentDraggableEvent?: DragEvent;
private currentDragEffectMsg?: string;
constructor(private snackBarService: MatSnackBar) {}
dragImageOffsetRight: DndDragImageOffsetFunction = (
event: DragEvent,
dragImage: Element
) => {
const dragImageComputedStyle = window.getComputedStyle(dragImage);
const paddingTop = parseFloat(dragImageComputedStyle.paddingTop) || 0;
const paddingLeft = parseFloat(dragImageComputedStyle.paddingLeft) || 0;
const borderTop = parseFloat(dragImageComputedStyle.borderTopWidth) || 0;
const borderLeft = parseFloat(dragImageComputedStyle.borderLeftWidth) || 0;
const x =
dragImage.clientWidth - (event.offsetX + paddingLeft + borderLeft);
return {
x: x,
y: event.offsetY + paddingTop + borderTop,
};
};
onDragStart(event: DragEvent) {
this.lastDropEvent = null;
this.currentDragEffectMsg = '';
this.currentDraggableEvent = event;
this.snackBarService.dismiss();
this.snackBarService.open('Drag started!', undefined, { duration: 2000 });
}
onDragged($event: DragEvent, effect: string) {
this.currentDragEffectMsg = `Drag ended with effect "${effect}"!`;
}
onDragEnd(event: DragEvent) {
this.currentDraggableEvent = event;
this.snackBarService.dismiss();
this.snackBarService.open(
this.currentDragEffectMsg || `Drag ended!`,
undefined,
{ duration: 2000 }
);
}
onDrop(event: DndDropEvent) {
this.lastDropEvent = event;
}
}
================================================
FILE: projects/demo/src/app/tree/tree.component.html
================================================
@for (item of list; track item) {
{{ item.content }}
@if (item.children) {
}
}
{{ draggableList | json }}
================================================
FILE: projects/demo/src/app/tree/tree.component.scss
================================================
.dndDraggingSource {
opacity: 0.5;
transform: scale(0.98);
pointer-events: none;
& > * {
pointer-events: none;
}
}
================================================
FILE: projects/demo/src/app/tree/tree.component.ts
================================================
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import {
DndDraggableDirective,
DndDropEvent,
DndDropzoneDirective,
DndPlaceholderRefDirective,
DropEffect,
} from 'ngx-drag-drop';
interface DraggableItem {
content: string;
children: DraggableItem[];
}
@Component({
selector: 'dnd-tree',
standalone: true,
imports: [
CommonModule,
DndDropzoneDirective,
DndPlaceholderRefDirective,
MatIconModule,
MatListModule,
DndDraggableDirective,
],
templateUrl: './tree.component.html',
styleUrls: ['./tree.component.scss'],
})
export default class TreeComponent {
draggableList: DraggableItem[] = [
{
content: 'Demo 1',
children: [
{
content: 'Nested 1',
children: [],
},
{
content: 'Nested 2',
children: [],
},
{
content: 'Nested 3',
children: [],
},
],
},
{
content: 'Demo 2',
children: [],
},
{
content: 'Demo 3',
children: [],
},
{
content: 'Demo 4',
children: [],
},
{
content: 'Demo 5',
children: [],
},
{
content: 'Demo 6',
children: [],
},
{
content: 'Demo 7',
children: [],
},
{
content: 'Demo 8',
children: [],
},
{
content: 'Demo 9',
children: [],
},
{
content: 'Demo 10',
children: [
{
content: 'Nested 1',
children: [],
},
{
content: 'Nested 2',
children: [],
},
{
content: 'Nested 3',
children: [],
},
],
},
];
onDragged(item: any, list: any[], effect: DropEffect) {
if (effect === 'move') {
const index = list.indexOf(item);
list.splice(index, 1);
}
}
onDrop(event: DndDropEvent, list?: any[]) {
console.log('onDrop', event, list);
if (list && (event.dropEffect === 'copy' || event.dropEffect === 'move')) {
let index = event.index;
if (typeof index === 'undefined') {
index = list.length;
}
list.splice(index, 0, event.data);
}
}
}
================================================
FILE: projects/demo/src/app/typed/typed.component.html
================================================
Fruits
@for (fruit of fruits; track trackByFruit(i, fruit); let i = $index) {
{{ fruit.type }} {{ fruit.id }}
}
Apples
@for (apple of apples; track trackByFruit(i, apple); let i = $index) {
{{ apple.type }} {{ apple.id }}
}
Bananas
@for (
banana of bananas;
track trackByFruit(i, banana);
let i = $index
) {
{{ banana.type }} {{ banana.id }}
}
================================================
FILE: projects/demo/src/app/typed/typed.component.scss
================================================
:host {
display: block;
box-sizing: border-box;
}
pre {
text-align: center;
padding: 8px;
}
.mat-mdc-list-item {
margin: 2px 0;
border: 1px solid gray;
}
.dndList {
transition: all 300ms ease;
padding: 8px;
overflow-y: auto;
&.dndDragover {
padding-top: 12px;
padding-bottom: 12px;
border-color: green;
}
}
.dndDragging {
border: 1px solid green;
}
.dndDraggingSource {
display: none;
}
.dndPlaceholder {
min-height: 48px;
border: 1px dashed green;
background-color: rgba(0, 0, 0, 0.1);
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
================================================
FILE: projects/demo/src/app/typed/typed.component.ts
================================================
import { Component } from '@angular/core';
import { MatListModule } from '@angular/material/list';
import {
DndDraggableDirective,
DndDropEvent,
DndDropzoneDirective,
DndPlaceholderRefDirective,
} from 'ngx-drag-drop';
type Apple = 'apple';
type Banana = 'banana';
type FruitType = Apple | Banana;
interface Fruit {
id: number;
type: FruitType;
}
let id = 0;
function createFruit(type: FruitType) {
return {
id: id++,
type: type,
};
}
function range(start: number, end: number) {
return Array.from({ length: end - start + 1 }, (_, i) => i);
}
@Component({
selector: 'dnd-typed',
templateUrl: './typed.component.html',
styleUrls: ['./typed.component.scss'],
standalone: true,
imports: [
MatListModule,
DndDropzoneDirective,
DndPlaceholderRefDirective,
DndDraggableDirective,
],
})
export default class TypedComponent {
public fruits: Fruit[] = range(0, 100).map(_ => {
const randomFruitType: FruitType = Math.random() < 0.5 ? 'apple' : 'banana';
return createFruit(randomFruitType);
});
public apples: Fruit[] = range(0, 12).map(_ => {
return createFruit('apple');
});
public bananas: Fruit[] = range(0, 10).map(_ => {
return createFruit('banana');
});
trackByFruit(index: number, fruit: Fruit) {
return fruit;
}
onDragged(index: number, fruit: Fruit, list: Fruit[]) {
const removeIndex = list.indexOf(fruit);
console.log(
`onDragged ngFor-index=${index}, item=${fruit}, removeIndex=${removeIndex}, list=${list.length}`
);
list.splice(removeIndex, 1);
}
onDrop(event: DndDropEvent, list: Fruit[]) {
console.log('onDrop', event, list.length);
let index = event.index;
if (typeof index === 'undefined') {
index = list.length;
}
list.splice(index, 0, event.data);
}
}
================================================
FILE: projects/demo/src/assets/.gitkeep
================================================
================================================
FILE: projects/demo/src/dragdroptouch.d.ts
================================================
declare module '@dragdroptouch/drag-drop-touch' {
export function enableDragDropTouch(
dragRoot?: Element,
dropRoot?: Element,
options?: Record
): void;
}
================================================
FILE: projects/demo/src/environments/environment.prod.ts
================================================
export const environment = {
production: true,
};
================================================
FILE: projects/demo/src/environments/environment.ts
================================================
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false,
};
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/plugins/zone-error'; // Included with Angular CLI.
================================================
FILE: projects/demo/src/index.html
================================================
NgxDragDrop
================================================
FILE: projects/demo/src/main.ts
================================================
import { enableProdMode, provideZoneChangeDetection } from '@angular/core';
import { platformBrowser } from '@angular/platform-browser';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
if (environment.production) {
enableProdMode();
}
platformBrowser()
.bootstrapModule(AppModule, {
applicationProviders: [provideZoneChangeDetection()],
})
.catch(err => console.error(err));
================================================
FILE: projects/demo/src/polyfills.ts
================================================
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes recent versions of Safari, Chrome (including
* Opera), Edge on the desktop, and iOS and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
/***************************************************************************************************
* APPLICATION IMPORTS
*/
import { enableDragDropTouch } from '@dragdroptouch/drag-drop-touch';
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js'; // Included with Angular CLI.
enableDragDropTouch();
================================================
FILE: projects/demo/src/styles.scss
================================================
@import 'bootstrap/scss/bootstrap.scss';
.drag-handle {
border: 1px solid #ddd;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
// Important because the material design would overwrite it in the mat-list
width: 28px !important;
height: 28px !important;
}
/* You can add global styles to this file, and also import other style files */
html,
body {
margin: 0;
height: 100%;
width: 100%;
min-height: 100vh;
display: flex;
flex: 1;
background: #fafafa;
}
.layout-padding {
padding: 8px;
}
.layout-padding > * {
margin: 8px;
}
.scrollable {
-webkit-overflow-scrolling: touch;
overflow-x: auto;
overflow-y: auto;
}
[dndHandle],
[draggable='true']:not(:has([dndHandle])) {
cursor: pointer;
}
[draggable].dndDraggableDisabled {
cursor: not-allowed;
opacity: 0.7;
}
[dndDropzone] {
// border: 1px red solid;
min-width: 200px;
min-height: 200px;
}
[dndDropzone].dndDropzoneDisabled {
cursor: no-drop;
opacity: 0.7;
border-color: gray;
}
[dndDropzone].dndDragover {
border-color: green;
}
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: Roboto, 'Helvetica Neue', sans-serif;
}
================================================
FILE: projects/demo/tsconfig.app.json
================================================
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "../../out-tsc/app",
"types": []
},
"files": ["src/main.ts", "src/polyfills.ts"],
"include": ["src/**/*.d.ts"]
}
================================================
FILE: projects/demo/tsconfig.spec.json
================================================
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "../../out-tsc/spec"
},
"files": ["src/test.ts", "src/polyfills.ts"],
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}
================================================
FILE: projects/dnd/.browserslistrc
================================================
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# For the full list of supported browsers by the Angular framework, please see:
# https://angular.io/guide/browser-support
# You can see what browsers were selected by your queries by running:
# npx browserslist
last 1 Chrome version
last 1 Firefox version
last 2 Edge major versions
last 2 Safari major versions
last 2 iOS major versions
Firefox ESR
================================================
FILE: projects/dnd/README.md
================================================
[](https://www.npmjs.com/package/ngx-drag-drop)
[](https://www.npmjs.com/package/ngx-drag-drop)
[](https://www.npmjs.com/package/ngx-drag-drop)
[](https://github.com/ChristofFritz/ngx-drag-drop/issues)
[](https://twitter.com/intent/tweet?text=Angular%20drag%20and%20drop%20with%20ease:&url=https://github.com/ChristofFritz/ngx-drag-drop)
# NgxDragDrop
[_Demo_](https://christoffritz.github.io/ngx-drag-drop/) / [_StackBlitz Issue
Template_](https://stackblitz.com/edit/ngx-drag-drop-issue-template)
```sh
npm install ngx-drag-drop
# or
pnpm add ngx-drag-drop
```
**Angular directives for declarative drag and drop using the HTML5 Drag-And-Drop API**
- sortable lists by using placeholder element (vertical and horizontal)
- nestable
- dropzones optionally support external/native draggables (img, txt, file)
- conditional drag/drop
- typed drag/drop
- utilize [EffectAllowed](https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/effectAllowed)
- custom CSS classes
- touch support by using a [polyfill](#touch-support)
- [AOT](https://angular.io/guide/aot-compiler) compatible
Port of [angular-drag-drop-lists](https://github.com/marceljuenemann/angular-drag-and-drop-lists) but without the lists :wink:
This has `dropzones` though :+1:
The idea is that the directive does not handle lists internally so the `dndDropzone` can be general purpose.
## Angular Version Compatibility
Starting with v13, the library major version matches the Angular major version.
| Angular | ngx-drag-drop |
| ------- | ------------- |
| 21.x | 21.x |
| 20.x | 20.x |
| 19.x | 19.x |
| 18.x | 18.x |
| 17.x | 17.x |
| 16.x | 16.x |
| 15.x | 15.x |
| 14.x | 14.x |
| 13.x | 13.x |
For older Angular versions (v4–v12), use ngx-drag-drop v2.x.
## Usage
`app.component.html`
```HTML
HANDLE
draggable ({{draggable.effectAllowed}})
DISABLED
DRAG_IMAGE
```
`app.component`
```JS
import {Component} from '@angular/core';
import {DndDropEvent} from 'ngx-drag-drop';
@Component()
export class AppComponent {
draggable = {
// note that data is handled with JSON.stringify/JSON.parse
// only set simple data or POJO's as methods will be lost
data: "myDragData",
effectAllowed: "all",
disable: false,
handle: false
};
onDragStart(event: DragEvent) {
console.log("drag started", JSON.stringify(event, null, 2));
}
onDragEnd(event: DragEvent) {
console.log("drag ended", JSON.stringify(event, null, 2));
}
onDraggableCopied(event: DragEvent) {
console.log("draggable copied", JSON.stringify(event, null, 2));
}
onDraggableLinked(event: DragEvent) {
console.log("draggable linked", JSON.stringify(event, null, 2));
}
onDraggableMoved(event: DragEvent) {
console.log("draggable moved", JSON.stringify(event, null, 2));
}
onDragCanceled(event: DragEvent) {
console.log("drag cancelled", JSON.stringify(event, null, 2));
}
onDragover(event: DragEvent) {
console.log("dragover", JSON.stringify(event, null, 2));
}
onDrop(event: DndDropEvent) {
console.log("dropped", JSON.stringify(event, null, 2));
}
}
```
`app.module`
```JS
import {BrowserModule} from '@angular/platform-browser';
import {NgModule} from '@angular/core';
import {DndModule} from 'ngx-drag-drop';
import {AppComponent} from './app.component';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
DndModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {
}
```
## API
```TS
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/dropEffect
export type DropEffect = "move" | "copy" | "link" | "none";
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/effectAllowed
export type EffectAllowed = DropEffect | "copyMove" | "copyLink" | "linkMove" | "all";
```
```TS
export type DndDragImageOffsetFunction = ( event:DragEvent, dragImage:Element ) => { x:number, y:number };
@Directive( {
selector: "[dndDraggable]"
} )
export declare class DndDraggableDirective {
// the data attached to the drag
dndDraggable: any;
// the allowed drop effect
dndEffectAllowed: EffectAllowed;
// optionally set the type of dragged data to restrict dropping on compatible dropzones
dndType?: string;
// conditionally disable the draggability
dndDisableIf: boolean;
dndDisableDragIf: boolean;
// set a custom class that is applied while dragging
dndDraggingClass: string = "dndDragging";
// set a custom class that is applied to only the src element while dragging
dndDraggingSourceClass: string = "dndDraggingSource";
// set the class that is applied when draggable is disabled by [dndDisableIf]
dndDraggableDisabledClass = "dndDraggableDisabled";
// enables to set a function for calculating custom dragimage offset
dndDragImageOffsetFunction:DndDragImageOffsetFunction = calculateDragImageOffset;
// emits on drag start
readonly dndStart: EventEmitter;
// emits on drag
readonly dndDrag: EventEmitter;
// emits on drag end
readonly dndEnd: EventEmitter;
// emits when the dragged item has been dropped with effect "move"
readonly dndMoved: EventEmitter;
// emits when the dragged item has been dropped with effect "copy"
readonly dndCopied: EventEmitter;
// emits when the dragged item has been dropped with effect "link"
readonly dndLinked: EventEmitter;
// emits when the drag is canceled
readonly dndCanceled: EventEmitter;
}
```
```TS
export interface DndDropEvent {
// the original drag event
event: DragEvent;
// the actual drop effect
dropEffect: DropEffect;
// true if the drag did not origin from a [dndDraggable]
isExternal:boolean;
// the data set on the [dndDraggable] that started the drag
// for external drags use the event property which contains the original drop event as this will be undefined
data?: any;
// the index where the draggable was dropped in a dropzone
// set only when using a placeholder
index?: number;
// if the dndType input on dndDraggable was set
// it will be transported here
type?: any;
}
@Directive( {
selector: "[dndDropzone]"
} )
export declare class DndDropzoneDirective {
// optionally restrict the allowed types
dndDropzone?: string[];
// set the allowed drop effect
dndEffectAllowed: EffectAllowed;
// conditionally disable the dropzone
dndDisableIf: boolean;
dndDisableDropIf: boolean;
// if draggables that are not [dndDraggable] are allowed to be dropped
// set to true if dragged text, images or files should be handled
dndAllowExternal: boolean;
// if its a horizontal list this influences how the placeholder position
// is calculated
dndHorizontal: boolean;
// set the class applied to the dropzone
// when a draggable is dragged over it
dndDragoverClass: string = "dndDragover";
// set the class applied to the dropzone
// when the dropzone is disabled by [dndDisableIf]
dndDropzoneDisabledClass = "dndDropzoneDisabled";
// emits when a draggable is dragged over the dropzone
readonly dndDragover: EventEmitter;
// emits on successful drop
readonly dndDrop: EventEmitter;
}
```
## Touch support
Install the `mobile-drag-drop` module available on npm.
Add the following lines to your js code
```JS
import { polyfill } from 'mobile-drag-drop';
// optional import of scroll behaviour
import { scrollBehaviourDragImageTranslateOverride } from "mobile-drag-drop/scroll-behaviour";
polyfill( {
// use this to make use of the scroll behaviour
dragImageTranslateOverride: scrollBehaviourDragImageTranslateOverride
} );
// workaround to make scroll prevent work in iOS Safari > 10
try {
window.addEventListener( "touchmove", function() { }, { passive: false } );
}
catch(e){}
```
For more info on the polyfill check it out on GitHub
https://github.com/timruffles/mobile-drag-drop
## Known issues
### Firefox
- Beware that Firefox does not support dragging on `` elements.
- `` and `` won't work.
- See https://bugzilla.mozilla.org/show_bug.cgi?id=568313
## Why?
HTML Drag-And-Drop API implementations are not behaving the same way across browsers.
The directives contained in this module enable declarative drag and drop that "just works" across browsers in a consistent way.
Credits go to the author and contributors of [angular-drag-drop-lists](https://github.com/marceljuenemann/angular-drag-and-drop-lists).
================================================
FILE: projects/dnd/ng-package.json
================================================
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/ngx-drag-drop",
"lib": {
"entryFile": "src/public-api.ts"
}
}
================================================
FILE: projects/dnd/package.json
================================================
{
"name": "ngx-drag-drop",
"version": "21.0.6",
"description": "Angular directives using the native HTML Drag And Drop API",
"repository": {
"type": "git",
"url": "https://github.com/ChristofFritz/ngx-drag-drop.git"
},
"homepage": "https://christoffritz.github.io/ngx-drag-drop/",
"author": {
"name": "Christof Fritz",
"email": "npm@christof.me"
},
"license": "BSD-3-Clause",
"keywords": [
"angular",
"html",
"drag",
"drop",
"dragdrop",
"dragndrop",
"dnd",
"directive",
"touch"
],
"peerDependencies": {
"@angular/common": ">=21.0.0",
"@angular/core": ">=21.0.0"
},
"dependencies": {
"tslib": "^2.3.0"
}
}
================================================
FILE: projects/dnd/src/lib/dnd-draggable.directive.spec.ts
================================================
import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { describe, it, expect, beforeEach } from 'vitest';
import {
DndDraggableDirective,
DndDragImageRefDirective,
} from './dnd-draggable.directive';
import { DndHandleDirective } from './dnd-handle.directive';
import { endDrag, dndState } from './dnd-state';
@Component({
standalone: true,
imports: [DndDraggableDirective],
template: `
drag me
`,
})
class BasicDraggableHost {}
@Component({
standalone: true,
imports: [DndDraggableDirective],
template: `drag me
`,
})
class DisabledDraggableHost {}
@Component({
standalone: true,
imports: [DndDraggableDirective, DndHandleDirective],
template: `
`,
})
class HandleDraggableHost {}
@Component({
standalone: true,
imports: [DndDraggableDirective, DndDragImageRefDirective],
template: `
`,
})
class DragImageHost {}
describe('DndDraggableDirective', () => {
let fixture: ComponentFixture;
let draggableEl: DebugElement;
beforeEach(async () => {
endDrag();
await TestBed.configureTestingModule({
imports: [BasicDraggableHost],
}).compileComponents();
fixture = TestBed.createComponent(BasicDraggableHost);
fixture.detectChanges();
draggableEl = fixture.debugElement.query(
By.directive(DndDraggableDirective)
);
});
it('should set draggable attribute to true', () => {
expect(draggableEl.nativeElement.getAttribute('draggable')).toBe('true');
});
it('should have the directive instance', () => {
const directive = draggableEl.injector.get(DndDraggableDirective);
expect(directive).toBeTruthy();
expect(directive.dndEffectAllowed).toBe('copyMove');
});
});
describe('DndDraggableDirective - disabled', () => {
let fixture: ComponentFixture;
let directive: DndDraggableDirective;
let draggableEl: DebugElement;
beforeEach(async () => {
endDrag();
await TestBed.configureTestingModule({
imports: [DisabledDraggableHost],
}).compileComponents();
fixture = TestBed.createComponent(DisabledDraggableHost);
fixture.detectChanges();
draggableEl = fixture.debugElement.query(
By.directive(DndDraggableDirective)
);
directive = draggableEl.injector.get(DndDraggableDirective);
});
it('should set draggable to false when disabled', () => {
directive.dndDisableIf = true;
expect(directive.draggable).toBe(false);
expect(
draggableEl.nativeElement.classList.contains('dndDraggableDisabled')
).toBe(true);
});
it('should add disabled class when disabled', () => {
directive.dndDisableIf = true;
expect(
draggableEl.nativeElement.classList.contains('dndDraggableDisabled')
).toBe(true);
});
it('should remove disabled class when re-enabled', () => {
directive.dndDisableIf = true;
directive.dndDisableIf = false;
expect(directive.draggable).toBe(true);
expect(
draggableEl.nativeElement.classList.contains('dndDraggableDisabled')
).toBe(false);
});
});
describe('DndDraggableDirective - handle', () => {
let fixture: ComponentFixture;
beforeEach(async () => {
endDrag();
await TestBed.configureTestingModule({
imports: [HandleDraggableHost],
}).compileComponents();
fixture = TestBed.createComponent(HandleDraggableHost);
fixture.detectChanges();
});
it('should register the handle', () => {
const draggableEl = fixture.debugElement.query(
By.directive(DndDraggableDirective)
);
const directive = draggableEl.injector.get(DndDraggableDirective);
// The handle registers itself on init — verify via the private field
expect((directive as any).dndHandle).toBeTruthy();
});
});
describe('DndDraggableDirective - drag image', () => {
let fixture: ComponentFixture;
beforeEach(async () => {
endDrag();
await TestBed.configureTestingModule({
imports: [DragImageHost],
}).compileComponents();
fixture = TestBed.createComponent(DragImageHost);
fixture.detectChanges();
});
it('should register the drag image element', () => {
const draggableEl = fixture.debugElement.query(
By.directive(DndDraggableDirective)
);
const directive = draggableEl.injector.get(DndDraggableDirective);
expect((directive as any).dndDragImageElementRef).toBeTruthy();
});
});
================================================
FILE: projects/dnd/src/lib/dnd-draggable.directive.ts
================================================
import {
AfterViewInit,
Directive,
ElementRef,
EventEmitter,
forwardRef,
HostBinding,
HostListener,
inject,
Input,
NgZone,
OnDestroy,
OnInit,
Output,
Renderer2,
} from '@angular/core';
import { DndHandleDirective } from './dnd-handle.directive';
import { dndState, endDrag, startDrag } from './dnd-state';
import { EffectAllowed } from './dnd-types';
import {
calculateDragImageOffset,
DndDragImageOffsetFunction,
DndEvent,
setDragData,
setDragImage,
} from './dnd-utils';
@Directive({ selector: '[dndDragImageRef]', standalone: true })
export class DndDragImageRefDirective implements OnInit {
dndDraggableDirective = inject(forwardRef(() => DndDraggableDirective));
elementRef: ElementRef = inject(ElementRef);
ngOnInit() {
this.dndDraggableDirective.registerDragImage(this.elementRef);
}
}
@Directive({ selector: '[dndDraggable]', standalone: true })
export class DndDraggableDirective implements AfterViewInit, OnDestroy {
@Input() dndDraggable: any;
@Input() dndEffectAllowed: EffectAllowed = 'copy';
@Input() dndType?: string;
@Input() dndDraggingClass = 'dndDragging';
@Input() dndDraggingSourceClass = 'dndDraggingSource';
@Input() dndDraggableDisabledClass = 'dndDraggableDisabled';
@Input() dndDragImageOffsetFunction: DndDragImageOffsetFunction =
calculateDragImageOffset;
@Output() readonly dndStart: EventEmitter =
new EventEmitter();
@Output() readonly dndDrag: EventEmitter =
new EventEmitter();
@Output() readonly dndEnd: EventEmitter =
new EventEmitter();
@Output() readonly dndMoved: EventEmitter =
new EventEmitter();
@Output() readonly dndCopied: EventEmitter =
new EventEmitter();
@Output() readonly dndLinked: EventEmitter =
new EventEmitter();
@Output() readonly dndCanceled: EventEmitter =
new EventEmitter();
@HostBinding('attr.draggable') draggable = true;
private dndHandle?: DndHandleDirective;
private dndDragImageElementRef?: ElementRef;
private dragImage: Element | undefined;
private isDragStarted: boolean = false;
private elementRef: ElementRef = inject(ElementRef);
private renderer = inject(Renderer2);
private ngZone = inject(NgZone);
@Input() set dndDisableIf(value: boolean) {
this.draggable = !value;
if (this.draggable) {
this.renderer.removeClass(
this.elementRef.nativeElement,
this.dndDraggableDisabledClass
);
} else {
this.renderer.addClass(
this.elementRef.nativeElement,
this.dndDraggableDisabledClass
);
}
}
@Input() set dndDisableDragIf(value: boolean) {
this.dndDisableIf = value;
}
ngAfterViewInit(): void {
this.ngZone.runOutsideAngular(() => {
this.elementRef.nativeElement.addEventListener(
'drag',
this.dragEventHandler
);
});
}
ngOnDestroy(): void {
this.elementRef.nativeElement.removeEventListener(
'drag',
this.dragEventHandler
);
if (this.isDragStarted) {
endDrag();
}
}
@HostListener('dragstart', ['$event']) onDragStart(event: DndEvent): boolean {
if (!this.draggable) {
return false;
}
// check if there is dnd handle and if the dnd handle was used to start the drag
if (this.dndHandle != null && event._dndUsingHandle == null) {
event.stopPropagation();
return false;
}
// initialize global state
startDrag(event, this.dndEffectAllowed, this.dndType);
this.isDragStarted = true;
setDragData(
event,
{ data: this.dndDraggable, type: this.dndType },
dndState.effectAllowed!
);
this.dragImage = this.determineDragImage();
// set dragging css class prior to setDragImage so styles are applied before
// TODO breaking change: add class to elementRef rather than drag image which could be another element
this.renderer.addClass(this.dragImage, this.dndDraggingClass);
// set custom dragimage if present
// set dragimage if drag is started from dndHandle
if (this.dndDragImageElementRef != null || event._dndUsingHandle != null) {
setDragImage(event, this.dragImage, this.dndDragImageOffsetFunction);
}
// add dragging source css class on first drag event
const unregister = this.renderer.listen(
this.elementRef.nativeElement,
'drag',
() => {
this.renderer.addClass(
this.elementRef.nativeElement,
this.dndDraggingSourceClass
);
unregister();
}
);
this.dndStart.emit(event);
event.stopPropagation();
setTimeout(() => {
if (this.isDragStarted) {
this.renderer.setStyle(this.dragImage, 'pointer-events', 'none');
}
}, 100);
return true;
}
onDrag(event: DragEvent) {
this.dndDrag.emit(event);
}
@HostListener('dragend', ['$event']) onDragEnd(event: DragEvent) {
if (!this.draggable || !this.isDragStarted) {
return;
}
// get drop effect from custom stored state as its not reliable across browsers
const dropEffect = dndState.dropEffect;
this.renderer.setStyle(this.dragImage, 'pointer-events', 'unset');
let dropEffectEmitter: EventEmitter;
switch (dropEffect) {
case 'copy':
dropEffectEmitter = this.dndCopied;
break;
case 'link':
dropEffectEmitter = this.dndLinked;
break;
case 'move':
dropEffectEmitter = this.dndMoved;
break;
default:
dropEffectEmitter = this.dndCanceled;
break;
}
dropEffectEmitter.emit(event);
this.dndEnd.emit(event);
// reset global state
endDrag();
this.isDragStarted = false;
this.renderer.removeClass(this.dragImage, this.dndDraggingClass);
// IE9 special hammering
window.setTimeout(() => {
this.renderer.removeClass(
this.elementRef.nativeElement,
this.dndDraggingSourceClass
);
}, 0);
event.stopPropagation();
}
registerDragHandle(handle: DndHandleDirective | undefined) {
this.dndHandle = handle;
}
registerDragImage(elementRef: ElementRef | undefined) {
this.dndDragImageElementRef = elementRef;
}
private readonly dragEventHandler: (event: DragEvent) => void = (
event: DragEvent
) => this.onDrag(event);
private determineDragImage(): Element {
// evaluate custom drag image existence
if (typeof this.dndDragImageElementRef !== 'undefined') {
return this.dndDragImageElementRef.nativeElement as Element;
} else {
return this.elementRef.nativeElement;
}
}
}
================================================
FILE: projects/dnd/src/lib/dnd-dropzone.directive.spec.ts
================================================
import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { describe, it, expect, beforeEach } from 'vitest';
import {
DndDropzoneDirective,
DndPlaceholderRefDirective,
} from './dnd-dropzone.directive';
import { endDrag } from './dnd-state';
@Component({
standalone: true,
imports: [DndDropzoneDirective],
template: `drop here
`,
})
class BasicDropzoneHost {}
@Component({
standalone: true,
imports: [DndDropzoneDirective],
template: `drop here
`,
})
class DisabledDropzoneHost {}
@Component({
standalone: true,
imports: [DndDropzoneDirective, DndPlaceholderRefDirective],
template: `
`,
})
class PlaceholderDropzoneHost {}
@Component({
standalone: true,
imports: [DndDropzoneDirective],
template: `typed drop
`,
})
class TypedDropzoneHost {}
describe('DndDropzoneDirective', () => {
let fixture: ComponentFixture;
let dropzoneEl: DebugElement;
beforeEach(async () => {
endDrag();
await TestBed.configureTestingModule({
imports: [BasicDropzoneHost],
}).compileComponents();
fixture = TestBed.createComponent(BasicDropzoneHost);
fixture.detectChanges();
dropzoneEl = fixture.debugElement.query(By.directive(DndDropzoneDirective));
});
it('should have the directive instance', () => {
const directive = dropzoneEl.injector.get(DndDropzoneDirective);
expect(directive).toBeTruthy();
});
it('should have default dndEffectAllowed', () => {
const directive = dropzoneEl.injector.get(DndDropzoneDirective);
expect(directive.dndEffectAllowed).toBe('uninitialized');
});
it('should have default dndDragoverClass', () => {
const directive = dropzoneEl.injector.get(DndDropzoneDirective);
expect(directive.dndDragoverClass).toBe('dndDragover');
});
});
describe('DndDropzoneDirective - disabled', () => {
let fixture: ComponentFixture;
let directive: DndDropzoneDirective;
let dropzoneEl: DebugElement;
beforeEach(async () => {
endDrag();
await TestBed.configureTestingModule({
imports: [DisabledDropzoneHost],
}).compileComponents();
fixture = TestBed.createComponent(DisabledDropzoneHost);
fixture.detectChanges();
dropzoneEl = fixture.debugElement.query(By.directive(DndDropzoneDirective));
directive = dropzoneEl.injector.get(DndDropzoneDirective);
});
it('should add disabled class when disabled', () => {
directive.dndDisableIf = true;
fixture.detectChanges();
expect(
dropzoneEl.nativeElement.classList.contains('dndDropzoneDisabled')
).toBe(true);
});
it('should remove disabled class when re-enabled', () => {
directive.dndDisableIf = true;
fixture.detectChanges();
directive.dndDisableIf = false;
fixture.detectChanges();
expect(
dropzoneEl.nativeElement.classList.contains('dndDropzoneDisabled')
).toBe(false);
});
});
describe('DndDropzoneDirective - placeholder', () => {
let fixture: ComponentFixture;
let dropzoneEl: DebugElement;
beforeEach(async () => {
endDrag();
await TestBed.configureTestingModule({
imports: [PlaceholderDropzoneHost],
}).compileComponents();
fixture = TestBed.createComponent(PlaceholderDropzoneHost);
fixture.detectChanges();
dropzoneEl = fixture.debugElement.query(By.directive(DndDropzoneDirective));
});
it('should remove placeholder from DOM on init', () => {
const placeholder = dropzoneEl.nativeElement.querySelector(
'[dndPlaceholderRef]'
);
expect(placeholder).toBeNull();
});
});
describe('DndDropzoneDirective - typed', () => {
let fixture: ComponentFixture;
let dropzoneEl: DebugElement;
beforeEach(async () => {
endDrag();
await TestBed.configureTestingModule({
imports: [TypedDropzoneHost],
}).compileComponents();
fixture = TestBed.createComponent(TypedDropzoneHost);
fixture.detectChanges();
dropzoneEl = fixture.debugElement.query(By.directive(DndDropzoneDirective));
});
it('should accept typed dropzone input', () => {
const directive = dropzoneEl.injector.get(DndDropzoneDirective);
expect(directive.dndDropzone).toEqual(['typeA', 'typeB']);
});
});
================================================
FILE: projects/dnd/src/lib/dnd-dropzone.directive.ts
================================================
import {
AfterViewInit,
ContentChild,
Directive,
ElementRef,
EventEmitter,
HostListener,
Input,
NgZone,
OnDestroy,
OnInit,
Output,
Renderer2,
} from '@angular/core';
import {
getDndType,
getDropEffect,
isExternalDrag,
setDropEffect,
} from './dnd-state';
import { DropEffect, EffectAllowed } from './dnd-types';
import {
DndEvent,
DragDropData,
getDirectChildElement,
getDropData,
shouldPositionPlaceholderBeforeElement,
} from './dnd-utils';
export interface DndDropEvent {
event: DragEvent;
dropEffect: DropEffect;
isExternal: boolean;
data?: any;
index?: number;
type?: any;
}
@Directive({ selector: '[dndPlaceholderRef]', standalone: true })
export class DndPlaceholderRefDirective implements OnInit {
constructor(public readonly elementRef: ElementRef) {}
ngOnInit() {
// placeholder has to be "invisible" to the cursor, or it would interfere with the dragover detection for the same dropzone
this.elementRef.nativeElement.style.pointerEvents = 'none';
}
}
@Directive({ selector: '[dndDropzone]', standalone: true })
export class DndDropzoneDirective implements AfterViewInit, OnDestroy {
@Input() dndDropzone?: string[] | '' = '';
@Input() dndEffectAllowed: EffectAllowed = 'uninitialized';
@Input() dndAllowExternal: boolean = false;
@Input() dndHorizontal: boolean = false;
@Input() dndDragoverClass: string = 'dndDragover';
@Input() dndDropzoneDisabledClass = 'dndDropzoneDisabled';
@Output() readonly dndDragover: EventEmitter =
new EventEmitter();
@Output() readonly dndDrop: EventEmitter =
new EventEmitter();
@ContentChild(DndPlaceholderRefDirective)
private readonly dndPlaceholderRef?: DndPlaceholderRefDirective;
private placeholder: Element | null = null;
private disabled: boolean = false;
private enterCount: number = 0;
constructor(
private ngZone: NgZone,
private elementRef: ElementRef,
private renderer: Renderer2
) {}
@Input() set dndDisableIf(value: boolean) {
this.disabled = value;
if (this.disabled) {
this.renderer.addClass(
this.elementRef.nativeElement,
this.dndDropzoneDisabledClass
);
} else {
this.renderer.removeClass(
this.elementRef.nativeElement,
this.dndDropzoneDisabledClass
);
}
}
@Input() set dndDisableDropIf(value: boolean) {
this.dndDisableIf = value;
}
ngAfterViewInit(): void {
this.placeholder = this.tryGetPlaceholder();
this.removePlaceholderFromDOM();
this.ngZone.runOutsideAngular(() => {
this.elementRef.nativeElement.addEventListener(
'dragenter',
this.dragEnterEventHandler
);
this.elementRef.nativeElement.addEventListener(
'dragover',
this.dragOverEventHandler
);
this.elementRef.nativeElement.addEventListener(
'dragleave',
this.dragLeaveEventHandler
);
});
}
ngOnDestroy(): void {
this.elementRef.nativeElement.removeEventListener(
'dragenter',
this.dragEnterEventHandler
);
this.elementRef.nativeElement.removeEventListener(
'dragover',
this.dragOverEventHandler
);
this.elementRef.nativeElement.removeEventListener(
'dragleave',
this.dragLeaveEventHandler
);
}
onDragEnter(event: DndEvent) {
this.enterCount++;
// check if another dropzone is activated
if (event._dndDropzoneActive === true) {
this.removePlaceholderFromDOM();
this.renderer.removeClass(
this.elementRef.nativeElement,
this.dndDragoverClass
);
return;
}
// set as active if the target element is inside this dropzone
if (event._dndDropzoneActive == null) {
if (this.elementRef.nativeElement.contains(event.target)) {
event._dndDropzoneActive = true;
}
}
// check if this drag event is allowed to drop on this dropzone
const type = getDndType(event);
if (!this.isDropAllowed(type)) {
return;
}
// allow the dragenter
event.preventDefault();
}
onDragOver(event: DragEvent) {
// With nested dropzones, we want to ignore this event if a child dropzone
// has already handled a dragover. Historically, event.stopPropagation() was
// used to prevent this bubbling, but that prevents any dragovers outside the
// ngx-drag-drop component, and stops other use cases such as scrolling on drag.
// Instead, we can check if the event was already prevented by a child and bail early.
if (event.defaultPrevented) {
return;
}
// check if this drag event is allowed to drop on this dropzone
const type = getDndType(event);
if (!this.isDropAllowed(type)) {
return;
}
this.checkAndUpdatePlaceholderPosition(event);
const dropEffect = getDropEffect(event, this.dndEffectAllowed);
if (dropEffect === 'none') {
this.cleanupDragoverState();
return;
}
// allow the dragover
event.preventDefault();
// set the drop effect
setDropEffect(event, dropEffect);
this.dndDragover.emit(event);
this.renderer.addClass(
this.elementRef.nativeElement,
this.dndDragoverClass
);
}
@HostListener('drop', ['$event']) onDrop(event: DragEvent) {
try {
// check if this drag event is allowed to drop on this dropzone
const type = getDndType(event);
if (!this.isDropAllowed(type)) {
return;
}
const data: DragDropData = getDropData(event, isExternalDrag());
if (!this.isDropAllowed(data.type)) {
return;
}
// signal custom drop handling
event.preventDefault();
const dropEffect = getDropEffect(event, this.dndEffectAllowed);
setDropEffect(event, dropEffect);
if (dropEffect === 'none') {
return;
}
const dropIndex = this.getPlaceholderIndex();
// if for whatever reason the placeholder is not present in the DOM but it should be there
// we don't allow/emit the drop event since it breaks the contract
// seems to only happen if drag and drop is executed faster than the DOM updates
if (dropIndex === -1) {
return;
}
this.dndDrop.emit({
event: event,
dropEffect: dropEffect,
isExternal: isExternalDrag(),
data: data.data,
index: dropIndex,
type: type,
});
event.stopPropagation();
} finally {
this.cleanupDragoverState();
}
}
onDragLeave(event: DndEvent) {
this.enterCount--;
// only clean up when all enter/leave pairs are balanced (cursor has truly left)
if (this.enterCount > 0) {
return;
}
this.cleanupDragoverState();
// cleanup drop effect when leaving dropzone
setDropEffect(event, 'none');
}
private readonly dragEnterEventHandler: (event: DragEvent) => void = (
event: DragEvent
) => this.onDragEnter(event);
private readonly dragOverEventHandler: (event: DragEvent) => void = (
event: DragEvent
) => this.onDragOver(event);
private readonly dragLeaveEventHandler: (event: DragEvent) => void = (
event: DragEvent
) => this.onDragLeave(event);
private isDropAllowed(type?: string): boolean {
// dropzone is disabled -> deny it
if (this.disabled) {
return false;
}
// if drag did not start from our directive
// and external drag sources are not allowed -> deny it
if (isExternalDrag() && !this.dndAllowExternal) {
return false;
}
// no filtering by types -> allow it
if (!this.dndDropzone) {
return true;
}
// no type set -> allow it
if (!type) {
return true;
}
if (!Array.isArray(this.dndDropzone)) {
throw new Error(
'dndDropzone: bound value to [dndDropzone] must be an array!'
);
}
// if dropzone contains type -> allow it
return this.dndDropzone.indexOf(type) !== -1;
}
private tryGetPlaceholder(): Element | null {
if (typeof this.dndPlaceholderRef !== 'undefined') {
return this.dndPlaceholderRef.elementRef.nativeElement as Element;
}
// TODO nasty workaround needed because if ng-container / template is used @ContentChild() or DI will fail because
// of wrong context see angular bug https://github.com/angular/angular/issues/13517
return this.elementRef.nativeElement.querySelector('[dndPlaceholderRef]');
}
private removePlaceholderFromDOM() {
if (this.placeholder !== null && this.placeholder.parentNode !== null) {
this.placeholder.parentNode.removeChild(this.placeholder);
}
}
private checkAndUpdatePlaceholderPosition(event: DragEvent): void {
if (this.placeholder === null) {
return;
}
// make sure the placeholder is in the DOM
if (this.placeholder.parentNode !== this.elementRef.nativeElement) {
this.renderer.appendChild(
this.elementRef.nativeElement,
this.placeholder
);
}
// update the position if the event originates from a child element of the dropzone
const directChild = getDirectChildElement(
this.elementRef.nativeElement,
event.target as Element
);
// early exit if no direct child or direct child is placeholder
if (directChild === null || directChild === this.placeholder) {
return;
}
const positionPlaceholderBeforeDirectChild =
shouldPositionPlaceholderBeforeElement(
event,
directChild,
this.dndHorizontal
);
if (positionPlaceholderBeforeDirectChild) {
// do insert before only if necessary
if (directChild.previousSibling !== this.placeholder) {
this.renderer.insertBefore(
this.elementRef.nativeElement,
this.placeholder,
directChild
);
}
} else {
// do insert after only if necessary
if (directChild.nextSibling !== this.placeholder) {
this.renderer.insertBefore(
this.elementRef.nativeElement,
this.placeholder,
directChild.nextSibling
);
}
}
}
private getPlaceholderIndex(): number | undefined {
if (this.placeholder === null) {
return undefined;
}
const element = this.elementRef.nativeElement as HTMLElement;
return Array.prototype.indexOf.call(element.children, this.placeholder);
}
private cleanupDragoverState() {
this.renderer.removeClass(
this.elementRef.nativeElement,
this.dndDragoverClass
);
this.enterCount = 0;
this.removePlaceholderFromDOM();
}
}
================================================
FILE: projects/dnd/src/lib/dnd-handle.directive.spec.ts
================================================
import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { describe, it, expect, beforeEach } from 'vitest';
import { DndDraggableDirective } from './dnd-draggable.directive';
import { DndHandleDirective } from './dnd-handle.directive';
import { endDrag } from './dnd-state';
@Component({
standalone: true,
imports: [DndDraggableDirective, DndHandleDirective],
template: `
`,
})
class HandleHost {}
describe('DndHandleDirective', () => {
let fixture: ComponentFixture;
let handleEl: DebugElement;
beforeEach(async () => {
endDrag();
await TestBed.configureTestingModule({
imports: [HandleHost],
}).compileComponents();
fixture = TestBed.createComponent(HandleHost);
fixture.detectChanges();
handleEl = fixture.debugElement.query(By.directive(DndHandleDirective));
});
it('should set draggable attribute on the handle', () => {
expect(handleEl.nativeElement.getAttribute('draggable')).toBe('true');
});
it('should set _dndUsingHandle on dragstart event', () => {
const directive = handleEl.injector.get(DndHandleDirective);
const event = {} as any;
directive.onDragEvent(event);
expect(event._dndUsingHandle).toBe(true);
});
it('should set _dndUsingHandle on dragend event', () => {
const directive = handleEl.injector.get(DndHandleDirective);
const event = {} as any;
directive.onDragEvent(event);
expect(event._dndUsingHandle).toBe(true);
});
it('should unregister handle on destroy', () => {
const draggableEl = fixture.debugElement.query(
By.directive(DndDraggableDirective)
);
const directive = draggableEl.injector.get(DndDraggableDirective);
expect((directive as any).dndHandle).toBeTruthy();
fixture.destroy();
expect((directive as any).dndHandle).toBeUndefined();
});
});
================================================
FILE: projects/dnd/src/lib/dnd-handle.directive.ts
================================================
import {
Directive,
HostBinding,
HostListener,
inject,
OnDestroy,
OnInit,
} from '@angular/core';
import { DndDraggableDirective } from './dnd-draggable.directive';
import { DndEvent } from './dnd-utils';
@Directive({ selector: '[dndHandle]', standalone: true })
export class DndHandleDirective implements OnInit, OnDestroy {
@HostBinding('attr.draggable') draggable = true;
dndDraggableDirective = inject(DndDraggableDirective);
ngOnInit() {
this.dndDraggableDirective.registerDragHandle(this);
}
ngOnDestroy(): void {
this.dndDraggableDirective.registerDragHandle(undefined);
}
@HostListener('dragstart', ['$event'])
@HostListener('dragend', ['$event'])
onDragEvent(event: DndEvent) {
event._dndUsingHandle = true;
}
}
================================================
FILE: projects/dnd/src/lib/dnd-state.spec.ts
================================================
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
startDrag,
endDrag,
setDropEffect,
getDropEffect,
getDndType,
isExternalDrag,
dndState,
} from './dnd-state';
import { CUSTOM_MIME_TYPE } from './dnd-utils';
function createMockDragEvent(overrides: Partial = {}): DragEvent {
return {
dataTransfer: {
effectAllowed: 'all',
dropEffect: 'none',
types: [],
},
ctrlKey: false,
altKey: false,
...overrides,
} as unknown as DragEvent;
}
describe('dnd-state', () => {
beforeEach(() => {
endDrag();
});
describe('startDrag', () => {
it('should set isDragging to true', () => {
const event = createMockDragEvent();
startDrag(event, 'all', undefined);
expect(dndState.isDragging).toBe(true);
});
it('should set effectAllowed on state and dataTransfer', () => {
const event = createMockDragEvent();
startDrag(event, 'copyMove', undefined);
expect(dndState.effectAllowed).toBe('copyMove');
expect(event.dataTransfer!.effectAllowed).toBe('copyMove');
});
it('should set type', () => {
const event = createMockDragEvent();
startDrag(event, 'all', 'myType');
expect(dndState.type).toBe('myType');
});
it('should reset dropEffect to none', () => {
const event = createMockDragEvent();
startDrag(event, 'all', undefined);
expect(dndState.dropEffect).toBe('none');
});
});
describe('endDrag', () => {
it('should reset all state', () => {
const event = createMockDragEvent();
startDrag(event, 'copyMove', 'test');
endDrag();
expect(dndState.isDragging).toBe(false);
expect(dndState.dropEffect).toBeUndefined();
expect(dndState.effectAllowed).toBeUndefined();
expect(dndState.type).toBeUndefined();
});
});
describe('setDropEffect', () => {
it('should update state when dragging', () => {
const event = createMockDragEvent();
startDrag(event, 'all', undefined);
setDropEffect(event, 'copy');
expect(dndState.dropEffect).toBe('copy');
expect(event.dataTransfer!.dropEffect).toBe('copy');
});
it('should not update state when not dragging', () => {
const event = createMockDragEvent();
setDropEffect(event, 'copy');
expect(dndState.dropEffect).toBeUndefined();
expect(event.dataTransfer!.dropEffect).toBe('copy');
});
});
describe('getDropEffect', () => {
it('should return first available effect', () => {
const event = createMockDragEvent();
expect(getDropEffect(event)).toBe('move');
});
it('should return "none" when no effects available', () => {
const event = createMockDragEvent({
dataTransfer: {
effectAllowed: 'none',
} as DataTransfer,
});
expect(getDropEffect(event)).toBe('none');
});
it('should filter by dndState effectAllowed when dragging', () => {
const startEvent = createMockDragEvent();
startDrag(startEvent, 'copy', undefined);
const event = createMockDragEvent();
expect(getDropEffect(event)).toBe('copy');
});
it('should filter by provided effectAllowed', () => {
const event = createMockDragEvent();
expect(getDropEffect(event, 'link')).toBe('link');
});
it('should return "copy" when ctrlKey is held and copy is available', () => {
const event = createMockDragEvent({ ctrlKey: true } as any);
expect(getDropEffect(event)).toBe('copy');
});
it('should return "link" when altKey is held and link is available', () => {
const event = createMockDragEvent({ altKey: true } as any);
expect(getDropEffect(event)).toBe('link');
});
it('should handle missing dataTransfer', () => {
const event = {
dataTransfer: null,
ctrlKey: false,
altKey: false,
} as unknown as DragEvent;
expect(getDropEffect(event)).toBe('move');
});
});
describe('getDndType', () => {
it('should return type from state when dragging', () => {
const event = createMockDragEvent();
startDrag(event, 'all', 'myType');
expect(getDndType(event)).toBe('myType');
});
it('should return undefined from state when dragging with no type', () => {
const event = createMockDragEvent();
startDrag(event, 'all', undefined);
expect(getDndType(event)).toBeUndefined();
});
it('should extract type from custom MIME type for external drag', () => {
const event = {
dataTransfer: {
types: [CUSTOM_MIME_TYPE + '-myType'],
},
} as unknown as DragEvent;
expect(getDndType(event)).toBe('myType');
});
it('should return undefined when no known MIME type on external drag', () => {
const event = {
dataTransfer: {
types: ['text/plain'],
},
} as unknown as DragEvent;
expect(getDndType(event)).toBeUndefined();
});
});
describe('isExternalDrag', () => {
it('should return true when not dragging', () => {
expect(isExternalDrag()).toBe(true);
});
it('should return false when dragging', () => {
const event = createMockDragEvent();
startDrag(event, 'all', undefined);
expect(isExternalDrag()).toBe(false);
});
});
});
================================================
FILE: projects/dnd/src/lib/dnd-state.ts
================================================
import { DropEffect, EffectAllowed } from './dnd-types';
import {
CUSTOM_MIME_TYPE,
DROP_EFFECTS,
filterEffects,
getWellKnownMimeType,
} from './dnd-utils';
export interface DndState {
isDragging: boolean;
dropEffect?: DropEffect;
effectAllowed?: EffectAllowed;
type?: string;
}
const _dndState: DndState = {
isDragging: false,
dropEffect: 'none',
effectAllowed: 'all',
type: undefined,
};
export function startDrag(
event: DragEvent,
effectAllowed: EffectAllowed,
type: string | undefined
) {
_dndState.isDragging = true;
_dndState.dropEffect = 'none';
_dndState.effectAllowed = effectAllowed;
_dndState.type = type;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = effectAllowed;
}
}
export function endDrag() {
_dndState.isDragging = false;
_dndState.dropEffect = undefined;
_dndState.effectAllowed = undefined;
_dndState.type = undefined;
}
export function setDropEffect(event: DragEvent, dropEffect: DropEffect) {
if (_dndState.isDragging === true) {
_dndState.dropEffect = dropEffect;
}
if (event.dataTransfer) {
event.dataTransfer.dropEffect = dropEffect;
}
}
export function getDropEffect(
event: DragEvent,
effectAllowed?: EffectAllowed | DropEffect
): DropEffect {
const dataTransferEffectAllowed: EffectAllowed = event.dataTransfer
? (event.dataTransfer.effectAllowed as EffectAllowed)
: 'uninitialized';
let effects = filterEffects(DROP_EFFECTS, dataTransferEffectAllowed);
if (_dndState.isDragging === true) {
effects = filterEffects(effects, _dndState.effectAllowed!);
}
if (effectAllowed) {
effects = filterEffects(effects, effectAllowed);
}
// MacOS automatically filters dataTransfer.effectAllowed depending on the modifier keys,
// therefore the following modifier keys will only affect other operating systems.
if (effects.length === 0) {
return 'none';
}
if (event.ctrlKey && effects.indexOf('copy') !== -1) {
return 'copy';
}
if (event.altKey && effects.indexOf('link') !== -1) {
return 'link';
}
return effects[0] as DropEffect;
}
export function getDndType(event: DragEvent): string | undefined {
if (_dndState.isDragging === true) {
return _dndState.type;
}
const mimeType = getWellKnownMimeType(event);
if (mimeType === null) {
return undefined;
}
return mimeType.substring(CUSTOM_MIME_TYPE.length + 1) || undefined;
}
export function isExternalDrag(): boolean {
return _dndState.isDragging === false;
}
export const dndState: Readonly = _dndState as Readonly;
================================================
FILE: projects/dnd/src/lib/dnd-types.ts
================================================
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/dropEffect
export type DropEffect = 'move' | 'copy' | 'link' | 'none';
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/effectAllowed
export type EffectAllowed =
| DropEffect
| 'copyMove'
| 'copyLink'
| 'linkMove'
| 'all'
| 'uninitialized';
================================================
FILE: projects/dnd/src/lib/dnd-utils.spec.ts
================================================
import { describe, it, expect, vi } from 'vitest';
import {
filterEffects,
getWellKnownMimeType,
setDragData,
getDropData,
getDirectChildElement,
shouldPositionPlaceholderBeforeElement,
calculateDragImageOffset,
setDragImage,
CUSTOM_MIME_TYPE,
} from './dnd-utils';
function createMockDragEvent(overrides: Partial = {}): DragEvent {
const dataStore = new Map();
return {
dataTransfer: {
types: [] as string[],
effectAllowed: 'all',
setData: vi.fn((type: string, data: string) => {
dataStore.set(type, data);
(overrides.dataTransfer as any)?.types?.push?.(type);
}),
getData: vi.fn((type: string) => dataStore.get(type) ?? ''),
setDragImage: vi.fn(),
},
...overrides,
} as unknown as DragEvent;
}
describe('filterEffects', () => {
const allEffects = ['move', 'copy', 'link'] as any;
it('should return all effects for "all"', () => {
expect(filterEffects(allEffects, 'all')).toEqual(['move', 'copy', 'link']);
});
it('should return all effects for "uninitialized"', () => {
expect(filterEffects(allEffects, 'uninitialized')).toEqual([
'move',
'copy',
'link',
]);
});
it('should filter to "copyMove"', () => {
expect(filterEffects(allEffects, 'copyMove')).toEqual(['move', 'copy']);
});
it('should filter to "copyLink"', () => {
expect(filterEffects(allEffects, 'copyLink')).toEqual(['copy', 'link']);
});
it('should filter to "linkMove"', () => {
expect(filterEffects(allEffects, 'linkMove')).toEqual(['move', 'link']);
});
it('should return single effect for exact match', () => {
expect(filterEffects(allEffects, 'copy')).toEqual(['copy']);
});
it('should return empty for "none"', () => {
expect(filterEffects(allEffects, 'none')).toEqual([]);
});
});
describe('getWellKnownMimeType', () => {
it('should return null when no dataTransfer', () => {
const event = { dataTransfer: null } as DragEvent;
expect(getWellKnownMimeType(event)).toBeNull();
});
it('should return custom MIME type when present', () => {
const customType = CUSTOM_MIME_TYPE + '-mytype';
const event = {
dataTransfer: { types: [customType] },
} as unknown as DragEvent;
expect(getWellKnownMimeType(event)).toBe(customType);
});
it('should return null for unknown MIME types', () => {
const event = {
dataTransfer: { types: ['text/plain'] },
} as unknown as DragEvent;
expect(getWellKnownMimeType(event)).toBeNull();
});
it('should return null for non-custom known MIME types', () => {
const event = {
dataTransfer: { types: ['application/json'] },
} as unknown as DragEvent;
expect(getWellKnownMimeType(event)).toBeNull();
});
});
describe('setDragData', () => {
it('should set data with custom MIME type', () => {
const event = createMockDragEvent();
const data = { data: 'test', type: 'mytype' };
setDragData(event, data, 'all');
expect(event.dataTransfer!.setData).toHaveBeenCalledWith(
CUSTOM_MIME_TYPE + '-mytype',
JSON.stringify(data)
);
});
it('should set data without type suffix when no type', () => {
const event = createMockDragEvent();
const data = { data: 'test' };
setDragData(event, data, 'all');
expect(event.dataTransfer!.setData).toHaveBeenCalledWith(
CUSTOM_MIME_TYPE,
JSON.stringify(data)
);
});
});
describe('getDropData', () => {
it('should parse data from custom MIME type for internal drag', () => {
const payload = { data: 'hello', type: 'mytype' };
const mimeType = CUSTOM_MIME_TYPE + '-mytype';
const event = {
dataTransfer: {
types: [mimeType],
getData: vi.fn(() => JSON.stringify(payload)),
},
} as unknown as DragEvent;
expect(getDropData(event, false)).toEqual(payload);
});
it('should return empty object for unknown MIME type on internal drag', () => {
const event = {
dataTransfer: {
types: ['text/plain'],
getData: vi.fn(() => ''),
},
} as unknown as DragEvent;
expect(getDropData(event, false)).toEqual({});
});
it('should return empty object for external drag with unknown MIME type', () => {
const event = {
dataTransfer: {
types: ['text/plain'],
getData: vi.fn(() => ''),
},
} as unknown as DragEvent;
expect(getDropData(event, true)).toEqual({});
});
it('should parse data for external drag with custom MIME type', () => {
const payload = { data: 'ext' };
const mimeType = CUSTOM_MIME_TYPE;
const event = {
dataTransfer: {
types: [mimeType],
getData: vi.fn(() => JSON.stringify(payload)),
},
} as unknown as DragEvent;
expect(getDropData(event, true)).toEqual(payload);
});
});
describe('getDirectChildElement', () => {
it('should return the direct child', () => {
const parent = document.createElement('div');
const child = document.createElement('span');
const grandchild = document.createElement('a');
parent.appendChild(child);
child.appendChild(grandchild);
expect(getDirectChildElement(parent, grandchild)).toBe(child);
});
it('should return the element itself when it is a direct child', () => {
const parent = document.createElement('div');
const child = document.createElement('span');
parent.appendChild(child);
expect(getDirectChildElement(parent, child)).toBe(child);
});
it('should return null when parent is not an ancestor', () => {
const parent = document.createElement('div');
const unrelated = document.createElement('span');
expect(getDirectChildElement(parent, unrelated)).toBeNull();
});
});
describe('shouldPositionPlaceholderBeforeElement', () => {
it('should return true when cursor is in upper half (vertical)', () => {
const element = document.createElement('div');
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
top: 100,
height: 50,
left: 0,
width: 100,
} as DOMRect);
const event = { clientY: 110, clientX: 0 } as DragEvent;
expect(shouldPositionPlaceholderBeforeElement(event, element, false)).toBe(
true
);
});
it('should return false when cursor is in lower half (vertical)', () => {
const element = document.createElement('div');
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
top: 100,
height: 50,
left: 0,
width: 100,
} as DOMRect);
const event = { clientY: 140, clientX: 0 } as DragEvent;
expect(shouldPositionPlaceholderBeforeElement(event, element, false)).toBe(
false
);
});
it('should return true when cursor is in left half (horizontal)', () => {
const element = document.createElement('div');
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
top: 0,
height: 50,
left: 100,
width: 100,
} as DOMRect);
const event = { clientX: 120, clientY: 0 } as DragEvent;
expect(shouldPositionPlaceholderBeforeElement(event, element, true)).toBe(
true
);
});
it('should return false when cursor is in right half (horizontal)', () => {
const element = document.createElement('div');
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
top: 0,
height: 50,
left: 100,
width: 100,
} as DOMRect);
const event = { clientX: 180, clientY: 0 } as DragEvent;
expect(shouldPositionPlaceholderBeforeElement(event, element, true)).toBe(
false
);
});
});
describe('calculateDragImageOffset', () => {
it('should calculate offset with padding and border', () => {
const element = document.createElement('div');
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
paddingTop: '10px',
paddingLeft: '5px',
borderTopWidth: '2px',
borderLeftWidth: '3px',
} as CSSStyleDeclaration);
const event = { offsetX: 20, offsetY: 30 } as DragEvent;
const result = calculateDragImageOffset(event, element);
expect(result).toEqual({ x: 28, y: 42 });
});
});
describe('setDragImage', () => {
it('should call setDragImage on dataTransfer with offset', () => {
const setDragImageFn = vi.fn();
const event = {
dataTransfer: { setDragImage: setDragImageFn },
} as unknown as DragEvent;
const element = document.createElement('div');
const offsetFn = vi.fn().mockReturnValue({ x: 10, y: 20 });
setDragImage(event, element, offsetFn);
expect(offsetFn).toHaveBeenCalledWith(event, element);
expect(setDragImageFn).toHaveBeenCalledWith(element, 10, 20);
});
});
================================================
FILE: projects/dnd/src/lib/dnd-utils.ts
================================================
import { DropEffect, EffectAllowed } from './dnd-types';
export interface DragDropData {
data?: any;
type?: string;
}
export interface DndEvent extends DragEvent {
_dndUsingHandle?: boolean;
_dndDropzoneActive?: true;
}
export type DndDragImageOffsetFunction = (
event: DragEvent,
dragImage: Element
) => { x: number; y: number };
export const DROP_EFFECTS = ['move', 'copy', 'link'] as DropEffect[];
export const CUSTOM_MIME_TYPE = 'application/x-dnd';
function mimeTypeIsCustom(mimeType: string) {
return mimeType.substring(0, CUSTOM_MIME_TYPE.length) === CUSTOM_MIME_TYPE;
}
export function getWellKnownMimeType(event: DragEvent): string | null {
if (event.dataTransfer) {
const types = event.dataTransfer.types;
for (let i = 0; i < types.length; i++) {
if (mimeTypeIsCustom(types[i])) {
return types[i];
}
}
}
return null;
}
export function setDragData(
event: DragEvent,
data: DragDropData,
effectAllowed: EffectAllowed
): void {
const mimeType = CUSTOM_MIME_TYPE + (data.type ? '-' + data.type : '');
const dataString = JSON.stringify(data);
event.dataTransfer?.setData(mimeType, dataString);
}
export function getDropData(
event: DragEvent,
dragIsExternal: boolean
): DragDropData {
const mimeType = getWellKnownMimeType(event);
if (dragIsExternal === true) {
if (mimeType !== null && mimeTypeIsCustom(mimeType)) {
return JSON.parse(event.dataTransfer?.getData(mimeType) ?? '{}');
}
return {};
}
if (mimeType !== null) {
return JSON.parse(event.dataTransfer?.getData(mimeType) ?? '{}');
}
return {};
}
export function filterEffects(
effects: DropEffect[],
allowed: EffectAllowed | DropEffect
): DropEffect[] {
if (allowed === 'all' || allowed === 'uninitialized') {
return effects;
}
return effects.filter(function (effect) {
return allowed.toLowerCase().indexOf(effect) !== -1;
});
}
export function getDirectChildElement(
parentElement: Element,
childElement: Element
): Element | null {
let directChild: Node = childElement;
while (directChild.parentNode !== parentElement) {
// reached root node without finding given parent
if (!directChild.parentNode) {
return null;
}
directChild = directChild.parentNode;
}
return directChild as Element;
}
export function shouldPositionPlaceholderBeforeElement(
event: DragEvent,
element: Element,
horizontal: boolean
) {
const bounds = element.getBoundingClientRect();
// If the pointer is in the upper half of the list item element,
// we position the placeholder before the list item, otherwise after it.
if (horizontal) {
return event.clientX < bounds.left + bounds.width / 2;
}
return event.clientY < bounds.top + bounds.height / 2;
}
export function calculateDragImageOffset(
event: DragEvent,
dragImage: Element
): { x: number; y: number } {
const dragImageComputedStyle = window.getComputedStyle(dragImage);
const paddingTop = parseFloat(dragImageComputedStyle.paddingTop) || 0;
const paddingLeft = parseFloat(dragImageComputedStyle.paddingLeft) || 0;
const borderTop = parseFloat(dragImageComputedStyle.borderTopWidth) || 0;
const borderLeft = parseFloat(dragImageComputedStyle.borderLeftWidth) || 0;
return {
x: event.offsetX + paddingLeft + borderLeft,
y: event.offsetY + paddingTop + borderTop,
};
}
export function setDragImage(
event: DragEvent,
dragImage: Element,
offsetFunction: DndDragImageOffsetFunction
): void {
const offset = offsetFunction(event, dragImage) || { x: 0, y: 0 };
(event.dataTransfer as any).setDragImage(dragImage, offset.x, offset.y);
}
================================================
FILE: projects/dnd/src/lib/dnd.module.ts
================================================
import { NgModule } from '@angular/core';
import {
DndDraggableDirective,
DndDragImageRefDirective,
} from './dnd-draggable.directive';
import {
DndDropzoneDirective,
DndPlaceholderRefDirective,
} from './dnd-dropzone.directive';
import { DndHandleDirective } from './dnd-handle.directive';
@NgModule({
exports: [
DndDraggableDirective,
DndDropzoneDirective,
DndHandleDirective,
DndPlaceholderRefDirective,
DndDragImageRefDirective,
],
imports: [
DndDragImageRefDirective,
DndDropzoneDirective,
DndHandleDirective,
DndPlaceholderRefDirective,
DndDraggableDirective,
],
})
export class DndModule {}
================================================
FILE: projects/dnd/src/public-api.ts
================================================
/*
* Public API Surface of dnd
*/
export * from './lib/dnd-draggable.directive';
export * from './lib/dnd-dropzone.directive';
export * from './lib/dnd-handle.directive';
export * from './lib/dnd-types';
export { DndDragImageOffsetFunction } from './lib/dnd-utils';
export * from './lib/dnd.module';
================================================
FILE: projects/dnd/src/test-setup.ts
================================================
import 'zone.js';
import 'zone.js/testing';
import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting,
} from '@angular/platform-browser-dynamic/testing';
// jsdom does not implement DragEvent
if (typeof globalThis.DragEvent === 'undefined') {
(globalThis as any).DragEvent = class DragEvent extends MouseEvent {
public dataTransfer: DataTransfer | null;
constructor(type: string, eventInitDict?: DragEventInit) {
super(type, eventInitDict);
this.dataTransfer = eventInitDict?.dataTransfer ?? null;
}
};
}
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting()
);
================================================
FILE: projects/dnd/tsconfig.lib.json
================================================
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "../../out-tsc/lib",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": ["src/test.ts", "**/*.spec.ts"]
}
================================================
FILE: projects/dnd/tsconfig.lib.prod.json
================================================
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"declarationMap": false
},
"angularCompilerOptions": {
"compilationMode": "partial"
}
}
================================================
FILE: projects/dnd/tsconfig.spec.json
================================================
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "../../out-tsc/spec",
"types": ["vitest/globals"]
},
"files": ["src/test-setup.ts"],
"include": ["**/*.spec.ts", "**/*.d.ts"]
}
================================================
FILE: renovate.json
================================================
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:best-practices"]
}
================================================
FILE: tsconfig.json
================================================
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"paths": {
"ngx-drag-drop": [
"./dist/ngx-drag-drop/ngx-drag-drop",
"./dist/ngx-drag-drop"
]
},
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "es2022",
"module": "es2022"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}
================================================
FILE: vitest.config.ts
================================================
///
import angular from '@analogjs/vite-plugin-angular';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
angular({
tsconfig: 'projects/dnd/tsconfig.spec.json',
}),
],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['projects/dnd/src/test-setup.ts'],
include: ['projects/dnd/src/**/*.spec.ts'],
coverage: {
provider: 'v8',
include: ['projects/dnd/src/lib/**/*.ts'],
exclude: ['**/*.spec.ts'],
reporter: ['text', 'json-summary', 'html'],
},
},
});