Home

Awesome

ember-drag-sort

Travis build status Ember Observer Score npm package version license MIT ember-versions 3.12+ node-versions 8+ ember-cli 3.15.1

Support

Bug reports and feature requests are very welcome. In case you have something to suggest or report, please file an issue to the issue queue. But first make sure there's no similar issue. ;)

If you're having trouble using this addon in your project, please file a properly structured question at StackOverflow. It is important that you use ember.js and ember-drag-sort tags for your question to be seen.

About

A drag'n'drop sortable list addon.

Features

Demo

https://kaliber5.github.io/ember-drag-sort/

Versions, branches and jQuery

Known issues

Browser support

Tested manually.

Works in desktop browsers:

Does not work on mobile browsers:

Installation

Install the addon:

ember i ember-drag-sort

Usage

Basic usage

The drag-sort-list component accepts two mandatory arguments:

The component accepts a block representing an individual item (the block is rendered multiple times, one per list item). It yields item and index.

<DragSortList
  @items         = {{this.items1}}
  @dragEndAction = {{this.dragEnd}}
  as |item|
>
  {{item.name}}
</DragSortList>

The drag end action

It is called on the source list component when the drag'n'drop operation is complete. It's called with a single argument -- an object with the following properties:

PropertyTypeDescription
groupStringGroup provided to the drag-sort-list component.
draggedItem<any>The list item being dragged.
sourceListArrayThe list where the sorting was initiated.
sourceIndexNumberThe initial index of the item in the source list.
targetListArrayThe list where sorting was finished. Will be the same as sourceList.
targetIndexNumberThe resulting index of the dragged item in the target list.

When sorting within one list, targetIndex assumes that the dragged item is not in the list.

For example, when your list is ['a', 'b', 'c'] and you put b after c, sourceIndex will be 1 and targetIndex will be 2. The initial index of c was 2, so you could suppose that target index after c is 3.

But targetIndex is calculated as if the dragged item b is not in the list: ['a', 'c']. Thus, next index after c will be 2.

This is because the array has three items with indexes 0, 1 and 2, so putting an item to position 3 would make no sense.

Here's the reference implementation of the dragEndAction action:

  @action
  dragEndAction ({sourceList, sourceIndex, targetList, targetIndex/* , sourceArgs, targetArgs */}) {
    if (sourceList === targetList && sourceIndex === targetIndex) return

    const item = sourceList.objectAt(sourceIndex)

    sourceList.removeAt(sourceIndex)
    targetList.insertAt(targetIndex, item)
  }

The drag start action

This action is called when a drag is beginning, and can be used to customize the drag image, or otherwise modify the data transfer. It's called with a single argument -- an object with the following properties:

PropertyTypeDescription
eventEventThe dragstart event.
elementDOMElementThe DOM element being dragged.
draggedItem<any>The list item being dragged.

This can be used to put margins around the list items without those margins being included in the drag image:

.the-item {
  margin: 20px;
}
<DragSortList
  items           = {{this.items1}}
  dragStartAction = {{action "dragStart"}}
  dragEndAction   = {{action "dragEnd"}}
  as |item|
>
  <div class="the-item">
    {{item.name}}
  </div>
</DragSortList>
actions: {
  dragStart({ event, element }) {
    let target = element.querySelector('.the-item');
    let { x, y } = element.getBoundingClientRect();
    // Set drag image, positioning it to align with `.the-item`'s position
    event.dataTransfer.setDragImage(target, event.clientX - x, event.clientY - y);
  }
}

The determine foreign position action

You may want to let the user drag items in and out of a list, without letting him rearrange items within a list. In that case the order of items is determined by the app.

Here's a use case. Your CMS allows the admin to put widgets in page areas. The admin panel has a number of lists representing page header, sidebar, footer, etc. The admin is allowed to rearrange widgets to his liking. And there's a list of unused widgets, the admin can drag items to and from that list, but unused items should always be sorted alphabetically.

To achieve that, pass a closure action determineForeignPositionAction into the list of unused items. This will prevent the user from sorting items in that list.

When the user drags a foreign items into such a list, the action will be called to determine the position of the item. Essentially, by running that action the ember-drag-sort addon asks the host app to suggest desired position of the dragged item.

The action is only called for foreign items. When the user drags an item out of the unsortable list but then drags the item back, it will appear on its original position.

The determineForeignPositionAction is called with with a single argument -- an object with the following properties:

PropertyTypeDescription
draggedItemThe item being dragged.
itemsArrayThe list where the item should be positioned.

This action must return an integer -- the desired position of the item.

The simplest implementation is to always put the item into the end of the list:

@action
determineForeignPosition ({/* draggedItem,  */items}) {
  return items.length
}

To sort items alphabetically, you can use lodash:

@action
determineForeignPosition ({draggedItem, items}) {
  return _.sortedIndex(items.toArray(), draggedItem)
}

Or do it by hand:

@action
determineForeignPosition ({draggedItem, items}) {
    return Ember.A(items.slice()) // make sure not to mutate the list; `Ember.A()` is typically redundant
      .addObject(draggedItem)
      .sortBy('name')
      .indexOf(draggedItem)
}

determineForeignPositionAction must not actually sort the list. It's only purpose is to suggest desired item position, which is necessary to display the placeholder.

Marking a list as a source only bucket

Sometimes you may have a need to define a bucket of items that is "source only". This means that you can only grab items from it to add to other buckets. The source bucket can never be reordered or modified by dragging items out of it. It is only a source to drag to other lists.

This list would be marked as source only:

<DragSortList
  @items         = {{this.items1}}
  @dragEndAction = {{this.dragEnd}}
  @sourceOnly    = {{true}}
  as |item|
>
  {{item.name}}
</DragSortList>

You could then have one or more other lists which you could drag the items from the source list into.

Passing additional arguments

When using drag-sort-list in a template, you can pass additional arguments to it. These arguments will be passed into the dragEndAction.

Here's a case where this is useful. Say, you have parent and child models, with a many-to-many relationship between the two. When you reorder a list of children, the new order must be persisted by calling .save() on the parent. But the dragEndAction does not have access to the parent by default!

To resolve this problem, pass the parent into the drag-sort-list component via the additionalArgs argument:

{{#each parents as |parent|}}
  <DragSortList
    @items          = {{this.parent.children}}
    @additionalArgs = {{hash parent=parent foo="bar"}}
    @dragEndAction  = {{this.dragEnd}}
    as |child|
  }}
    {{child.name}}
  </DragSortList>
{{/each}}

Now you can access the parent of both source and target lists in the dragEndAction. The value of additionalArgs will be exposed as sourceArgs and targetArgs:

@action
dragEndAction({ sourceList, sourceIndex, sourceArgs, targetList, targetIndex, targetArgs }) {
  if (sourceModel === targetModel && sourceIndex === targetIndex) return;

  const item = sourceList.objectAt(sourceIndex);
  sourceList.removeAt(sourceIndex);
  targetList.insertAt(targetIndex, item);

  // Access the parent via `sourceArgs` and `targetArgs`
  sourceArgs.parent.save();
  targetArgs.parent.save();

  console.log(sourceArgs.foo); // => "bar"
  console.log(targetArgs.foo); // => "bar"
}

drag-sort-list arguments reference

ArgumentTypeDefault valueDescription
itemsEmber Array<required>An array of items to display and offer sorting.
dragEndActionClosure action<required>This callback will be called on source list when sorting is complete. See above for details.
dragStartActionClosure action<required>This callback will be called on source list when dragging is starting. See above for details.
determineForeignPositionActionClosure action or undefinedundefinedWhen provided, used to determine the position of the placeholder when dragging a foreign item into the list. When not provided, the user is able to determine the order. See above for details.
group<any>undefinedUsed to restrict dragging between multiple lists to only some of those lists. Typically a string.
draggingEnabledBooleantrueDisables sorting. Useful when dragEndAction is an async operation.
childClassString""HTML class applied to list item components.
childTagNameString"div"tagName applied to list item components.
handleString, typically "[draggable]", or nullnullSelector of the drag handle element. When provided, items can only be dragged by handle. :warning: The handle element must have draggable="true" attribute.
isHorizontalBooleanfalseDisplays the list horizontally. :warning: Horizontal lists don't work well when nested.
isRtlBooleanfalseRTL - Right to left. Might be useful for certain languages. :warning: Has no effect on vertical lists.
additionalArgs<any>undefinedA catch-all for additional arguments you may want to access in the dragEndAction. Can be used for things like passing the parent of the list in for saving hasMany relationships.

HTML classes

drag-sort-list component has HTML class dragSortList. It also assumes the following classes dynamically:

HTML classApplied when...
-isEmptyThe given list is empty.
-draggingEnabledDragging is enabled via the draggingEnabled attribute.
-isDraggingDragging is in progress and the given list is either a source list or belongs to the same group as the source list.
-isDraggingOverDragging is in progress and the placeholder is within the given list. This class is removed from a list when an item is dragged into a different list.
-isExpandedDragging is in progress and the given list is either empty or contains only the dragged item. Used to give some height to the list, so that the item can be dragged back into it.

The individual item component has HTML class dragSortItem. It also assumes the following classes dynamically:

HTML classApplied when...
-isDraggedThe given item is the one being dragged. Used to hide the item from the list.
-isDraggingOverDragged item is positioned either above/before or below/after the given item.
-placeholderBeforeDragged item is positioned above/before the given item.
-placeholderAfterDragged item is positioned below/after the given item.

CSS concerns

When dragging, the dragged item is hidden via the -isDragged HTML class that applies display: none.

The placeholder (drop target) is shown via the -placeholderBefore or -placeholderAfter HTML classes. These classes apply padding to the given list item, and the placeholder is an absolutely positioned :before pseudo-element. A similar pseudo-element is applied to an -isExpanded list (see above).

For sorting to work correctly, you must not apply padding to the list HTML element. If you need some padding on the list, apply it to its parent element.

You must not apply any padding or margin to list item elements either. If you need padding between list items, apply it to HTML elements that you pass into list items.

Avoid collapsing margins between list items and between list item and list. Collapsing margins may cause a jumping glitch.

Events

There's an Ember service called dragSort. You can listen to the following events on it, using the Evented API.

Each event is called with as single argument, which is an object with properties. For the description of properties, see dragEndAction documentation above.

Event nameDescriptionArgument properties
startSorting has started.group, draggedItem, sourceList, sourceIndex
sortDragged item has been moved within a list. The list is referenced as targetList.group, draggedItem, sourceList, sourceIndex, targetList, oldTargetIndex, newTargetIndex
moveItem has been dragged into a different list.group, draggedItem, sourceList, sourceIndex, oldTargetList, newTargetList, targetIndex
endSorting has ended.group, draggedItem, sourceList, sourceIndex, targetList, targetIndex

Test helpers

trigger

trigger is a low-level test helper that can be imported like this:

import trigger from 'ember-drag-sort/utils/trigger'

It accepts three arguments:

ArgumentTypeDescription
elementString, DOM element or jQuery collectionSelector or element to trigger an operation on.
eventNameStringFor list: dragenter; for list item: dragstart, dragover or dragend.
aboveBooleanOnly for dragover. Whether to put placeholder above (true) or below (false) target item.

The order of operations is the following:

  1. dragstart on the element to drag.
  2. dragenter on target list that the dragged element should be moved into (optional).
  3. dragover on target element, the one that the dragged element should be dropped next to. Provide third argument to indicate above or below.
  4. dragover on the dragged element.

After performing the operations, you must wait for async behavior.

See this addon's integration test for example.

sort

sort is a high-level test helper that moves an item to a new position within the same list.

It can be imported like this:

import {sort} from 'ember-drag-sort/utils/trigger'

It accepts the following arguments:

ArgumentTypeRequired?Description
sourceListString, DOM element or jQuery collectionyesSelector or element of the drag-sort-list component.
sourceIndexIntegeryesZero-based index of the item to pick up.
targetIndexIntegeryesZero-based index of the item to drop picked item on top of, calculated while the picked item is still on its original position.
aboveBooleanyesWhether to drop picked item above (true) or below (false) target item.
handleSelectorStringnoProvide if handles are used in the list

After executing sort in a test, perform a wait using wait, andThen or await.

Example:

import {sort} from 'ember-drag-sort/utils/trigger'

test('sorting a list', async function (assert) {
  await visit('/')

  const list = document.querySelector('.dragSortList')

  await sort(list, 0, 1, false)

  const expectedTitles = ['Bar', 'Foo', 'Baz', 'Quux']

  assert.equal(list.childElementCount, 4)

  expectedTitles.forEach((expectedTitle, k) => {
    m = `List #0 item #${k} content title`
    expect(list.children[k].textContent.trim(), m).equal(expectedTitle)
  })
}))

move

move is a high-level test helper that moves an item from one list into another.

It can be imported like this:

import {sort} from 'ember-drag-sort/utils/trigger'

It accepts the following arguments:

ArgumentTypeRequired?Description
sourceListString, DOM element or jQuery collectionyesSelector or element of the source drag-sort-list component.
sourceIndexIntegeryesZero-based index of the item to pick up.
targetListString, DOM element or jQuery collectionyesSelector or element of the target drag-sort-list component.
targetIndexIntegernoZero-based index of the item to drop picked item on top of, calculated while the picked item is still on its original position. When omitted, adds item to the end of the target list. Must be omitted when moving into an empty list.
aboveBooleanyes if targetList is providedWhether to drop picked item above (true) or below (false) target item.
handleSelectorStringnoProvide if handles are used in the list

After executing sort, perform a wait using wait, andThen or await.

Example:

  const [list0, list1] = document.querySelectorAll('.dragSortList')

  await move(list0, 0, list1, 1, false)

This will pick the first item from list0 and drop it below the second item of list1.

See this addon's acceptance test for example.

Page object components

This addon provides page object components.

Importing page object components

Page object components mixed into your app's tests/pages/components/ directory

import dragSortList from '<your-app-name>/tests/pages/components/drag-sort-list'
import dragSortItem from '<your-app-name>/tests/pages/components/drag-sort-item'

Normally, you only need to import dragSortList. dragSortItem is available as part of dragSortList.

When used in a test, the dragSortList page object component offers the following properties and methods:

PropertyTypeDescription
itemsPage Object CollectionEach item is a dragSortItem page object component.
draggingEnabledBooleanChecks for -draggingEnabled class on the component (see above).
isDraggingBooleanChecks for -isDragging class on the component (see above).
isDraggingOverBooleanChecks for -isDraggingOver class on the component (see above).
isEmptyBooleanChecks for -isEmpty class on the component (see above).
isExpandedBooleanChecks for -isExpanded class on the component (see above).
dragEnter()MethodCalls trigger helper on current list with 'dragenter'.
sort(...)MethodCalls sort helper on current list. See below for arguments.
move(...)MethodCalls move helper on current list. See below for arguments.

The dragSortItem page object component offers the following properties and methods:

PropertyTypeDescription
contentPage Object ComponentRepresents the content of every dragSortItem. Available only via the factory imported from {dragSortList}.
draggableBooleanWhether the item is draggable
isDraggedBooleanChecks for -isDragged class on the component (see above).
isDraggingOverBooleanChecks for -isDraggingOver class on the component (see above).
placeholderAboveBooleanChecks for -placeholderBefore class on the component (see above).
placeholderBelowBooleanChecks for -placeholderAfter class on the component (see above).
dragStart()MethodCalls trigger helper on current item with 'dragstart'.
dragOver(above)MethodCalls trigger helper on current item with 'dragover' and above.
dragEnd()MethodCalls trigger helper on current item with 'dragend'.

Additionally, both page object components offer the following properties and methods:

PropertyTypeDescription
$jQuery CollectionCurrent element wrapped in jQuery.
emptyBooleanWhether current element is empty, ignoring whitespace.
existsBooleanWhether current element exists. When element does not exist, returns false without raising an exception.
indexIntegerIndex of current element within its parent.
visibleBooleanPageObject.isVisible.
attr(string)MethodReturns given attribute value on current element (PageObject.attribute).
click()MethodPageObject.clickable.
contains(selectorOrElement)MethodReturns whether given element exists inside current element.
hasClass(string)MethodReturns whether current element has given class.
text()MethodReturns text of current element (PageObject.text).

Including page object components into your page objects

Here's how you include dragSortList into your page object:

// tests/pages/index.js

import {create, visitable} from 'ember-cli-page-object'
import dragSortList from '<your-app-name>/tests/pages/components/drag-sort-list'

export default create({
  visit:        visitable('/'),
  sortableList: dragSortList
})

Extending the dragSortList page object component

If you want to provide custom descriptors for the dragSortList page object component, use the spread operator:

import {create, hasClass, visitable} from 'ember-cli-page-object'
import dragSortList from '<your-app-name>/tests/pages/components/drag-sort-list'

export default create({
  visit:        visitable('/'),
  sortableList: {
    ...dragSortList,
    isActive: hasClass('active')
  }
})

Extending the dragSortItem page object component

You can not provide a custom descriptor for dragSortItem, but you can describe its content. For example, you can describe the following template:

<DragSortList
  @items         = {{this.items}}
  @dragEndAction = {{this.dragEndAction}}
  as |item|
>
  <div class = 'drag-sort-item-content'>
    {{item.name}}
  </div>
</DragSortList>

...by importing the page object component factory from {dragSortList} and passing your item description into it like this:

import {create, visitable} from 'ember-cli-page-object'
import {dragSortList} from '<your-app-name>/tests/pages/components/drag-sort-list'

export default create({
  visit:        visitable('/'),
  sortableList: dragSortList({
    title: text()
  })
})

In a test, list items are available as sortableList.items(). Item content is available as sortableList.items(index).content.

For example, to assert the title of the first item in a list, using the page object from the last example, you can do this:

assert.equal(sortableList.items(0).content.title, "Foo")

Providing the drag handle selector

If you're using drag handles, you must pass a drag handle selector to the factory as the second argument:

import {create, visitable} from 'ember-cli-page-object'
import {dragSortList} from '<your-app-name>/tests/pages/components/drag-sort-list'

export default create({
  visit:        visitable('/'),
  sortableList: dragSortList({}, '[draggable]')
})

If you don't want to provide custom list item content, pass an empty object.

This assumes that every list item has a handle with the same selector.

The selector must be discoverable within list item content. In other words, the handle is accessed via listItem.querySelector(handleSelector).

If you're describing a nested list, use the > combinator in your drag handle selector, so that drag handles of child items aren't selected.

Sorting the dragSortList page object component

Inside your acceptance test, you can use the sort method on the dragSortList page object component.

To rearrange items within a single list, call dragSortList.sort() with three arguments:

ArgumentTypeRequiredDescription
sourceIndexIntegeryesZero-based index of the item to pick up.
targetIndexIntegeryesZero-based index of the item to drop picked item on top of, calculated while the picked item is still on its original position.
aboveBooleanyesWhether to drop picked item above (true) or below (false) target item.

After executing sort, perform a wait using await or andThen().

Example:

test('sorting a list', async function (assert) {
  await page.visit()

  const list = page.sortableList

  await list.sort(0, 1, false)

  const expectedTitles = ['Bar', 'Foo', 'Baz', 'Quux']

  assert.equal(list.items().count, 4)

  expectedTitles.forEach((expectedTitle, k) => {
    m = `List #0 item #${k} content title`
    expect(list.items(k).content.title, m).equal(expectedTitle)
  })
}))

To move an item from one list to another, call dragSortList.move() with four arguments:

ArgumentTypeRequiredDescription
sourceIndexIntegeryesZero-based index of the item to pick up.
targetListPage object componentyesThe page object of the other sortable list component.
targetIndexIntegernoZero-based index of the item to drop picked item on top of, calculated while the picked item is still on its original position. When omitted, adds item to the end of the target list. Must be omitted when moving into an empty list.
aboveBooleanyes if targetList is providedWhether to drop picked item above (true) or below (false) target item.

After executing sort, perform a wait using await or andThen().

Example:

const list1 = page.sortableList1
const list2 = page.sortableList2

await list1.sort(0, list2, 1, false)

This will pick the first item from list1 and drop it below the second item of list2.

See this addon's acceptance test for example.

Development

Use Volta

Use Volta to automatically pick correct Node and Yarn versions.

Do not use npm, use yarn

This project uses Yarn to lock dependencies. You can install yarn with npm i -g yarn.

Installation for development

For more information on using ember-cli, visit https://ember-cli.com/.

Running

Branch names

Main branches are named as gen-1, gen-2, etc. Default branch on GitHub is where active development happens.

This naming scheme is due to the fact that this project uses SemVer. As a result, major version number will rise very quickly, without any correlation with actual major changes in the app.

The number in the branch name, "generation", is supposed to be incremented in these cases:

Pull requests are welcome from feature branches. Make sure to discus proposed changes with addon maintainers to avoid wasted effort.

Updating the table of contents

Maintaining the TOC by hand is extremely tedious. Use this tiny webapp to generate the TOC automatically. Enable the first two checkboxes there.

Demo deployment

This command will deploy the app to https://kaliber5/ember-drag-sort.github.io/ember-drag-sort/ :

ember deploy prod

Credits

Built by @lolmaus and contributors.

Notable contributors: @frysch, @rwwagner90.

Conceieved in Firecracker.

Reimplemented in Deveo/Perforce.

Currently developed and maintained by kaliber5.

<img src="https://www.kaliber5.de/assets/images/kaliber5@2x.png" alt="kaliber5" width="134" height="40">

License

MIT.