JavaScript Drag and Drop

Not long ago I was tasked with creating a complex JavaScript drag and drop UI. One in which gave users the ability to move MULTIPLE items at once. This was a feature I’d never done prior. And this was a project I was the lead frontend developer for. Additionally, the client was the United States Senate. Not only would members of Congress be using a UI I created, but every mouse or hand gesture was a POST request.

Git Repo

Most of which is irrelevant. I’m a developer, and therefore need to ensure that whatever I build works flawlessly. Regardless of the client. I wanted to be 100% prepared for the upcoming task. In turn, I created this UI. Though far from exact, it demonstrates creative drag abilities via JSON content. Working demo here. Code analysis below.

JavaScript Drag and Drop

Yes, technically this is TypeScript. But because I originally wrote it in JavaScript, and there’s lot’s of it here, I’m gonna say JavaScript. I digress. The point of this is to explore a draggable UI without a library. See how it’s working, and maybe get new ideas for a project you’re working on. In retrospect, I’d avoid using innerHTML. It’s certainly inbuilt and oftentimes useful, but I’ve read from numerous resources how it’s a vulnerability. Yes, deterministic of how it’s used, but I now avoid it entirely.

  • items is an array of objects.
  • This is a group reference. If we created two directories named colors and cities, items would look like
  • groupItem is the same thing, but for a new group item. In the event we’d like to add a new value to the UI. A value we’d like to then drag somewhere. Enter something in that field, and it’ll appear as list value to be dragged.
  • groupIndex on the other hand is the unsortedItems array indice. Picking up Honda would make the groupIndex 1. Dragging Orange, groupIndex is 0. Dragging blue, groupIndex is 3.
  • result holds the incoming JSON data.
  • uploadedCount signifies how the elements are currently “uploaded”. After all, this JavaScript drag and drop is a mock file uploader. This array contains references to the elements in each “directory”.
  • itemTitle is the value: {{ snippetThree }} shows how it’s being used.
  • itemName is a value that’s captured from the template via ngModel. It’s a banana in a boat [()] meaning two way data binding. In turn, when a value’s entered in the ‘Add Group Name’ field, it flows to the model.
  • addNewItem is an array of list category references. {{ snippetFour }}. These are the lists of draggable items. When they’re being dragged, they become
  • itemIndex helps to determine which named item was moved from the items array.
  • namedElements is an array of HTML elements housed in each “directory”.
  • directoryElements is an array of “directory” drop zones. When we drag, directoryElements sends up a flag saying, “hey, these are the available drop zones.”
  • draggedItem is the raw text of the current item being dragged.
  • draggedElement is the HTML element in full.
items: { title: string }[] = [];
private unsubscribe$ = new Subject<void>();
groupItem: string;
groupIndex: any;
result: any = [];
uploadedCount: any[] = [];
itemTitle: string;
itemName: string;
addNewItem: { active: boolean }[] = [];
itemIndex: string;
namedElements: any = [];
directoryElements: any;
draggedItem: any;
draggedElement: any;

@ViewChildren('typeName') typeName: QueryList<ElementRef[]>;
@ViewChild('newDirectory', { static: false }) newDirectory: ElementRef;
@ViewChild('newGroup', { static: false }) newGroup: ElementRef;

constructor(private _http: HttpClient) {
  // Get JSON
  // Import HttpClientModule to the Parent Module
  this._http.get<Response[]>('path-to-json')
  .pipe(takeUntil(this.unsubscribe$))
  .subscribe(val => {
    this.result = val;
    this.setInputBooleans();
  });
}

// Array's same length as JSON...Hides Add New Name Field
setInputBooleans() {
  this.addNewItem.push({ active: false });
}

// Add Dragstart Event to All
dragStartHandler(e: any) {
  this.groupIndex = e.target.dataset.group;
  this.itemIndex = e.target.dataset.name;
  this.draggedElement = e.target;
  this.draggedItem = this.draggedElement.querySelector('p').innerText;
  this.draggedElement.classList.add('dragging');
  this.collectDropZones();
}

dragEndHandler(e: any) {
  this.draggedElement.classList.remove('dragging');
}

// From UI to Drop Zones
collectDropZones() {
  this.directoryElements = Array.from(
    document.querySelectorAll('.child-container .drop-zone')
  );
  this.directoryElements.forEach((val: any, i: number) => {
    
    // Drag Leave Event
    val.addEventListener('dragleave', () => {
      this.removeActiveDropZone(val);
    });

    // Drag Over Event
    val.addEventListener('dragover', (e: any) => {
      e.preventDefault();
      val.classList.add('drag-zone-active');
    });

    // Drop Event
    val.addEventListener('drop', (e: any) => {

      // Kill Other Events
      e.stopImmediatePropagation();
      this.removeActiveDropZone(val);
      this.result[this.groupIndex].items.splice([this.itemIndex], 1);
      let elem = document.createElement('li');
      elem.setAttribute('data-groupIndex', this.groupIndex);

      elem.innerHTML = '<p>' + this.draggedItem +
      "</p><div class='named-elem'><span>⤺</span></div>";
      val.parentElement.querySelector('.dragged-items').appendChild(elem);
      this.namedElements = Array.from(document.querySelectorAll('.named-elem'));

      this.namedElements.forEach((elem: any, indice: number) => {
        elem.addEventListener('click', (e: any) => {
          this.uploadedCount[i] =
          Array.from(val.parentElement.querySelectorAll('.dragged-items li'));
          e.target.parentElement.parentElement.remove();
          e.stopImmediatePropagation();
          let groupI = e.target.parentElement.parentElement.dataset.groupindex;
          this.result[groupI].items.push(
            {name: e.target.parentElement.parentElement.querySelector('p').innerText});
          this.namedElements = Array.from(document.querySelectorAll('.named-elem'));
          this.uploadedCount[i].pop();
        });
      });
    });
  });
}

toggleBlock(i: number) {
  this.directoryElements = Array.from(
    document.querySelectorAll('.child-container .drop-zone')
  );
  let elem = this.directoryElements[i].parentElement.querySelector('.toggle-block'),
      arrow = this.directoryElements[i].parentElement.querySelector('.toggle-block .arrow');
  
  elem.classList.toggle('close-block');
  arrow.classList.toggle('rotate-arrow');
}

// Stop Drop Active Indicator
removeActiveDropZone(elem: any) {
  elem.classList.remove('drag-zone-active');
}

// Add New Directory/Dropzone
itemAdded() {
  if (this.itemTitle != undefined) {
    this.newDirectory.nativeElement.classList.remove('required-field');
    this.items.push({ title: this.itemTitle });
    this.itemTitle = '';
    this.removeInputs();
  } else {
    this.newDirectory.nativeElement.classList.add('required-field');
  }
  this.itemTitle = undefined;
}

// Delete Directory/ Dropzone
// Return Elems to Groups
deleteItem(i: number) {
  this.items.splice(i, 1);
  this.uploadedCount.splice(i, 1);
  let cContainer = document.querySelector('.child-container .toggle-block ul');
  if (cContainer.innerHTML != '') {
    let arr = Array.from(cContainer.querySelectorAll('li'));
    for (var i = 0; i < arr.length; i++) {
      let groupI = arr[i].dataset.groupindex;
      this.result[groupI].items.push({
        name: arr[i].querySelector('p').innerText,
      });
    }
  }
}

// Delete Draggable
deleteName(i: number, j: number) {
  this.result[i].items.splice(j, 1);
  this.typeName.length - 1;
  this.removeInputs();
}

// Add New Draggable Group
addGroup() {
  if (this.itemName != undefined) {
    this.newGroup.nativeElement.classList.remove('required-field');
    this.result.push({items: [{ name: this.itemName }]});
    this.itemName = '';
    this.setInputBooleans();
    this.removeInputs();
  } else {
    this.newGroup.nativeElement.classList.add('required-field');
  }
  this.itemName = undefined;
}

// Add New Drop Zone on Keypress (Enter)
getKeyCode(e: any) {
  e.code === 'Enter' ? this.itemAdded() : '';
}

// Add New Group on Keypress (Enter)
addGroupKeyCode(e: any) {
  e.code === 'Enter' ? this.addGroup() : '';
}

// Add New Item on Keypress (Enter)
addNewItemField(e: any, i: number) {
  e.code === 'Enter' ? this.pushNamedItem(i) : '';
}

addNamedItem(i: number) {
  this.removeInputs();
  this.addNewItem[i].active = true;
}

pushNamedItem(i: number) {
  this.groupItem.length > 10 ? (this.groupItem = this.groupItem.slice(0, 10) + ' . . .') : '';
  this.result[i].items.push({ name: this.groupItem });
  this.groupItem = undefined;
  this.addNewItem[i].active = false;
}

// Hide Input Fields
removeInputs() {
  this.addNewItem.forEach((val: any) => {
    val.active = false;
  });
}

ngOnDestroy(){
  this.unsubscribe$.next;
  this.unsubscribe$.complete();
}

Bring it All Together

There’s no way around it. If you’re creating a UI like this without a library, it’s gonna be complex. Plus, factoring in POST requests at every drop like my project called for&mdash;not fun. Though many would advocate using a library, I try to avoid using libs whenever possible. Why? I find lots of feature based libraries to be more problematic than helpful simply because every project is unique. And using one creates boundaries. In turn, I need to fight the feature to have it act the way the project calls for.

Side note: I certainly use libraries like every other dev, but sparingly. This project demonstrates one such example. It's a ChartJS UI. To see larger fully developed projects however, view them here.

Such is the case with a draggable plugin. You’ll spend more time trying to get the lib to mimic the intended behavior vs creating a system of your own. At the end of the day, you’ve got a flexible system. One in which you know what every line is doing.

<div class="wrapper">
  <header [ngClass]="{ 'border-btm': items.length > 0 }">
    <p>Drag-Drop UI</p>
    <small>—  No Libraries or Packages  —</small>
    <div class="add-btn">Add Directory</div>
    <div class="add-new">
      <input
        placeholder="Add directory name"
        #newDirectory
        [(ngModel)]="itemTitle"
        (keydown)="getKeyCode($event)"/>
      <button (click)="itemAdded()">Add</button>
    </div>
  </header>
  <div
    class="selected-content"
    [ngClass]="{ hasDirectories: items.length > 0 }">
    <div class="child-container" *ngFor="let item of items; let i = index" id="{{ 'group' + i }}">
      <hr />
      <p class="group-index">{{ items[i].title }}</p>
      <div class="delete" (click)="deleteItem(i)">
        <i>×</i>
        <div class="faux-btn"></div>
      </div>
      <div class="drop-zone"><p class="ddText">Drag & Drop</p></div>
      <div class="toggle-block">
        <p *ngIf="uploadedCount[i] != undefined && uploadedCount[i].length > 0">
          {{ uploadedCount[i].length }} Items
        </p>
        <ul class="dragged-items"></ul>
        <span (click)="toggleBlock(i)" class="arrow"
          *ngIf="uploadedCount[i] != undefined && uploadedCount[i].length > 0">
          ▼
        </span>
      </div>
    </div>
  </div>
  <div class="add-btn group">Add Group Item</div>
  <div class="add-new mr-20">
    <input placeholder="Add group item" (keydown)="addGroupKeyCode($event)" [(ngModel)]="itemName" #newGroup class="mt-10"/>
    <button (click)="addGroup()" class="mt-10">Add</button>
  </div>
  <div class="unsorted">
    <div *ngFor="let items of result; let i = index">
      <div
        *ngFor="let type of result[i].items; let j = index"
        draggable="true"
        (dragstart)="dragStartHandler($event)"
        (dragend)="dragEndHandler($event)"
        #typeName
        [attr.data-group]="i"
        [attr.data-name]="j"
      >
        <div>
          <p>{{ type.name }}</p>
          <span>×</span>
          <div class="delete-item" (click)="deleteName(i, j)"></div>
        </div>
      </div>
      <div class="add-new-name" *ngIf="result[i].items.length > 0">
        <span (click)="addNamedItem(i)">+</span>
        <div class="add-new" *ngIf="addNewItem[i].active">
          <input
            placeholder="Add new item"
            [(ngModel)]="groupItem"
            (keydown)="addNewItemField($event, i)"/>
          <button (click)="pushNamedItem(i)">Add</button>
        </div>
      </div>
    </div>
  </div>
</div>

This could no doubt be improved with more specified data types. But time was of the essence when I built a low-fi mock. One in which to emulate an actual feature in a proprietary application.

In conclusion, see additional fully developed projects right here. If you need a working version of this JavaScript drag and drop UI, this is the demo.

1 Trackback / Pingback

  1. Rest Countries API App - Frontend Development

Leave a Reply

Your email address will not be published.


*