├── README.md ├── index.html └── jquery.nestable.js /README.md: -------------------------------------------------------------------------------- 1 | Nestable 2 | ======== 3 | 4 | ## PLEASE NOTE 5 | 6 | **I cannot provide any support or guidance beyond this README. If this code helps you that's great but I have no plans to develop Nestable beyond this demo (it's not a final product and has limited functionality). I cannot reply to any requests for help.** 7 | 8 | * * * 9 | 10 | ### Drag & drop hierarchical list with mouse and touch compatibility (jQuery / Zepto plugin) 11 | 12 | [**Try Nestable Demo**](http://dbushell.github.com/Nestable/) 13 | 14 | Nestable is an experimental example and not under active development. If it suits your requirements feel free to expand upon it! 15 | 16 | ## Usage 17 | 18 | Write your nested HTML lists like so: 19 | 20 |
21 |
    22 |
  1. 23 |
    Item 1
    24 |
  2. 25 |
  3. 26 |
    Item 2
    27 |
  4. 28 |
  5. 29 |
    Item 3
    30 |
      31 |
    1. 32 |
      Item 4
      33 |
    2. 34 |
    3. 35 |
      Item 5
      36 |
    4. 37 |
    38 |
  6. 39 |
40 |
41 | 42 | Then activate with jQuery like so: 43 | 44 | $('.dd').nestable({ /* config options */ }); 45 | 46 | ### Events 47 | 48 | The `change` event is fired when items are reordered. 49 | 50 | $('.dd').on('change', function() { 51 | /* on change event */ 52 | }); 53 | 54 | ### Methods 55 | 56 | You can get a serialised object with all `data-*` attributes for each item. 57 | 58 | $('.dd').nestable('serialize'); 59 | 60 | The serialised JSON for the example above would be: 61 | 62 | [{"id":1},{"id":2},{"id":3,"children":[{"id":4},{"id":5}]}] 63 | 64 | ### Configuration 65 | 66 | You can change the follow options: 67 | 68 | * `maxDepth` number of levels an item can be nested (default `5`) 69 | * `group` group ID to allow dragging between lists (default `0`) 70 | 71 | These advanced config options are also available: 72 | 73 | * `listNodeName` The HTML element to create for lists (default `'ol'`) 74 | * `itemNodeName` The HTML element to create for list items (default `'li'`) 75 | * `rootClass` The class of the root element `.nestable()` was used on (default `'dd'`) 76 | * `listClass` The class of all list elements (default `'dd-list'`) 77 | * `itemClass` The class of all list item elements (default `'dd-item'`) 78 | * `dragClass` The class applied to the list element that is being dragged (default `'dd-dragel'`) 79 | * `handleClass` The class of the content element inside each list item (default `'dd-handle'`) 80 | * `collapsedClass` The class applied to lists that have been collapsed (default `'dd-collapsed'`) 81 | * `placeClass` The class of the placeholder element (default `'dd-placeholder'`) 82 | * `emptyClass` The class used for empty list placeholder elements (default `'dd-empty'`) 83 | * `expandBtnHTML` The HTML text used to generate a list item expand button (default `''`) 84 | * `collapseBtnHTML` The HTML text used to generate a list item collapse button (default `''`) 85 | 86 | **Inspect the [Nestable Demo](http://dbushell.github.com/Nestable/) for guidance.** 87 | 88 | ## Change Log 89 | 90 | ### 15th October 2012 91 | 92 | * Merge for Zepto.js support 93 | * Merge fix for remove/detach items 94 | 95 | ### 27th June 2012 96 | 97 | * Added `maxDepth` option (default to 5) 98 | * Added empty placeholder 99 | * Updated CSS class structure with options for `listClass` and `itemClass`. 100 | * Fixed to allow drag and drop between multiple Nestable instances (off by default). 101 | * Added `group` option to enabled the above. 102 | 103 | * * * 104 | 105 | Author: David Bushell [http://dbushell.com](http://dbushell.com/) [@dbushell](http://twitter.com/dbushell/) 106 | 107 | Copyright © 2012 David Bushell | BSD & MIT license 108 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Nestable 11 | 147 | 148 | 149 | 150 |

Nestable

151 | 152 |

Drag & drop hierarchical list with mouse and touch compatibility (jQuery plugin)

153 | 154 |

Download on GitHub

155 | 156 |
157 | 158 | Share on Twitter 159 | 160 |
161 | 162 | 163 |

PLEASE NOTE: I cannot provide any support or guidance beyond this README. If this code helps you that's great but I have no plans to develop Nestable beyond this demo (it's not a final product and has limited functionality). I cannot reply to any requests for help.

164 | 165 | 166 | 167 | 168 | 169 | 170 |
171 | 172 |
173 |
    174 |
  1. 175 |
    Item 1
    176 |
  2. 177 |
  3. 178 |
    Item 2
    179 |
      180 |
    1. Item 3
    2. 181 |
    3. Item 4
    4. 182 |
    5. 183 |
      Item 5
      184 |
        185 |
      1. Item 6
      2. 186 |
      3. Item 7
      4. 187 |
      5. Item 8
      6. 188 |
      189 |
    6. 190 |
    7. Item 9
    8. 191 |
    9. Item 10
    10. 192 |
    193 |
  4. 194 |
  5. 195 |
    Item 11
    196 |
  6. 197 |
  7. 198 |
    Item 12
    199 |
  8. 200 |
201 |
202 | 203 |
204 |
    205 |
  1. 206 |
    Item 13
    207 |
  2. 208 |
  3. 209 |
    Item 14
    210 |
  4. 211 |
  5. 212 |
    Item 15
    213 |
      214 |
    1. Item 16
    2. 215 |
    3. Item 17
    4. 216 |
    5. Item 18
    6. 217 |
    218 |
  6. 219 |
220 |
221 | 222 |
223 | 224 |

Serialised Output (per list)

225 | 226 | 227 | 228 | 229 |

 

230 | 231 |
232 | 233 |

Draggable Handles

234 | 235 |

If you're clever with your CSS and markup this can be achieved without any JavaScript changes.

236 | 237 |
238 |
    239 |
  1. 240 |
    Drag
    Item 13
    241 |
  2. 242 |
  3. 243 |
    Drag
    Item 14
    244 |
  4. 245 |
  5. 246 |
    Drag
    Item 15
    247 |
      248 |
    1. 249 |
      Drag
      Item 16
      250 |
    2. 251 |
    3. 252 |
      Drag
      Item 17
      253 |
    4. 254 |
    5. 255 |
      Drag
      Item 18
      256 |
    6. 257 |
    258 |
  6. 259 |
260 |
261 | 262 |
263 | 264 |

Copyright © David Bushell | Made for Browser

265 | 266 | 267 | 268 | 316 | 317 | 318 | -------------------------------------------------------------------------------- /jquery.nestable.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Nestable jQuery Plugin - Copyright (c) 2012 David Bushell - http://dbushell.com/ 3 | * Dual-licensed under the BSD or MIT licenses 4 | */ 5 | ;(function($, window, document, undefined) 6 | { 7 | var hasTouch = 'ontouchstart' in document; 8 | 9 | /** 10 | * Detect CSS pointer-events property 11 | * events are normally disabled on the dragging element to avoid conflicts 12 | * https://github.com/ausi/Feature-detection-technique-for-pointer-events/blob/master/modernizr-pointerevents.js 13 | */ 14 | var hasPointerEvents = (function() 15 | { 16 | var el = document.createElement('div'), 17 | docEl = document.documentElement; 18 | if (!('pointerEvents' in el.style)) { 19 | return false; 20 | } 21 | el.style.pointerEvents = 'auto'; 22 | el.style.pointerEvents = 'x'; 23 | docEl.appendChild(el); 24 | var supports = window.getComputedStyle && window.getComputedStyle(el, '').pointerEvents === 'auto'; 25 | docEl.removeChild(el); 26 | return !!supports; 27 | })(); 28 | 29 | var defaults = { 30 | listNodeName : 'ol', 31 | itemNodeName : 'li', 32 | rootClass : 'dd', 33 | listClass : 'dd-list', 34 | itemClass : 'dd-item', 35 | dragClass : 'dd-dragel', 36 | handleClass : 'dd-handle', 37 | collapsedClass : 'dd-collapsed', 38 | placeClass : 'dd-placeholder', 39 | noDragClass : 'dd-nodrag', 40 | emptyClass : 'dd-empty', 41 | expandBtnHTML : '', 42 | collapseBtnHTML : '', 43 | group : 0, 44 | maxDepth : 5, 45 | threshold : 20 46 | }; 47 | 48 | function Plugin(element, options) 49 | { 50 | this.w = $(document); 51 | this.el = $(element); 52 | this.options = $.extend({}, defaults, options); 53 | this.init(); 54 | } 55 | 56 | Plugin.prototype = { 57 | 58 | init: function() 59 | { 60 | var list = this; 61 | 62 | list.reset(); 63 | 64 | list.el.data('nestable-group', this.options.group); 65 | 66 | list.placeEl = $('
'); 67 | 68 | $.each(this.el.find(list.options.itemNodeName), function(k, el) { 69 | list.setParent($(el)); 70 | }); 71 | 72 | list.el.on('click', 'button', function(e) { 73 | if (list.dragEl) { 74 | return; 75 | } 76 | var target = $(e.currentTarget), 77 | action = target.data('action'), 78 | item = target.parent(list.options.itemNodeName); 79 | if (action === 'collapse') { 80 | list.collapseItem(item); 81 | } 82 | if (action === 'expand') { 83 | list.expandItem(item); 84 | } 85 | }); 86 | 87 | var onStartEvent = function(e) 88 | { 89 | var handle = $(e.target); 90 | if (!handle.hasClass(list.options.handleClass)) { 91 | if (handle.closest('.' + list.options.noDragClass).length) { 92 | return; 93 | } 94 | handle = handle.closest('.' + list.options.handleClass); 95 | } 96 | 97 | if (!handle.length || list.dragEl) { 98 | return; 99 | } 100 | 101 | list.isTouch = /^touch/.test(e.type); 102 | if (list.isTouch && e.touches.length !== 1) { 103 | return; 104 | } 105 | 106 | e.preventDefault(); 107 | list.dragStart(e.touches ? e.touches[0] : e); 108 | }; 109 | 110 | var onMoveEvent = function(e) 111 | { 112 | if (list.dragEl) { 113 | e.preventDefault(); 114 | list.dragMove(e.touches ? e.touches[0] : e); 115 | } 116 | }; 117 | 118 | var onEndEvent = function(e) 119 | { 120 | if (list.dragEl) { 121 | e.preventDefault(); 122 | list.dragStop(e.touches ? e.touches[0] : e); 123 | } 124 | }; 125 | 126 | if (hasTouch) { 127 | list.el[0].addEventListener('touchstart', onStartEvent, false); 128 | window.addEventListener('touchmove', onMoveEvent, false); 129 | window.addEventListener('touchend', onEndEvent, false); 130 | window.addEventListener('touchcancel', onEndEvent, false); 131 | } 132 | 133 | list.el.on('mousedown', onStartEvent); 134 | list.w.on('mousemove', onMoveEvent); 135 | list.w.on('mouseup', onEndEvent); 136 | 137 | }, 138 | 139 | serialize: function() 140 | { 141 | var data, 142 | depth = 0, 143 | list = this; 144 | step = function(level, depth) 145 | { 146 | var array = [ ], 147 | items = level.children(list.options.itemNodeName); 148 | items.each(function() 149 | { 150 | var li = $(this), 151 | item = $.extend({}, li.data()), 152 | sub = li.children(list.options.listNodeName); 153 | if (sub.length) { 154 | item.children = step(sub, depth + 1); 155 | } 156 | array.push(item); 157 | }); 158 | return array; 159 | }; 160 | data = step(list.el.find(list.options.listNodeName).first(), depth); 161 | return data; 162 | }, 163 | 164 | serialise: function() 165 | { 166 | return this.serialize(); 167 | }, 168 | 169 | reset: function() 170 | { 171 | this.mouse = { 172 | offsetX : 0, 173 | offsetY : 0, 174 | startX : 0, 175 | startY : 0, 176 | lastX : 0, 177 | lastY : 0, 178 | nowX : 0, 179 | nowY : 0, 180 | distX : 0, 181 | distY : 0, 182 | dirAx : 0, 183 | dirX : 0, 184 | dirY : 0, 185 | lastDirX : 0, 186 | lastDirY : 0, 187 | distAxX : 0, 188 | distAxY : 0 189 | }; 190 | this.isTouch = false; 191 | this.moving = false; 192 | this.dragEl = null; 193 | this.dragRootEl = null; 194 | this.dragDepth = 0; 195 | this.hasNewRoot = false; 196 | this.pointEl = null; 197 | }, 198 | 199 | expandItem: function(li) 200 | { 201 | li.removeClass(this.options.collapsedClass); 202 | li.children('[data-action="expand"]').hide(); 203 | li.children('[data-action="collapse"]').show(); 204 | li.children(this.options.listNodeName).show(); 205 | }, 206 | 207 | collapseItem: function(li) 208 | { 209 | var lists = li.children(this.options.listNodeName); 210 | if (lists.length) { 211 | li.addClass(this.options.collapsedClass); 212 | li.children('[data-action="collapse"]').hide(); 213 | li.children('[data-action="expand"]').show(); 214 | li.children(this.options.listNodeName).hide(); 215 | } 216 | }, 217 | 218 | expandAll: function() 219 | { 220 | var list = this; 221 | list.el.find(list.options.itemNodeName).each(function() { 222 | list.expandItem($(this)); 223 | }); 224 | }, 225 | 226 | collapseAll: function() 227 | { 228 | var list = this; 229 | list.el.find(list.options.itemNodeName).each(function() { 230 | list.collapseItem($(this)); 231 | }); 232 | }, 233 | 234 | setParent: function(li) 235 | { 236 | if (li.children(this.options.listNodeName).length) { 237 | li.prepend($(this.options.expandBtnHTML)); 238 | li.prepend($(this.options.collapseBtnHTML)); 239 | } 240 | li.children('[data-action="expand"]').hide(); 241 | }, 242 | 243 | unsetParent: function(li) 244 | { 245 | li.removeClass(this.options.collapsedClass); 246 | li.children('[data-action]').remove(); 247 | li.children(this.options.listNodeName).remove(); 248 | }, 249 | 250 | dragStart: function(e) 251 | { 252 | var mouse = this.mouse, 253 | target = $(e.target), 254 | dragItem = target.closest(this.options.itemNodeName); 255 | 256 | this.placeEl.css('height', dragItem.height()); 257 | 258 | mouse.offsetX = e.offsetX !== undefined ? e.offsetX : e.pageX - target.offset().left; 259 | mouse.offsetY = e.offsetY !== undefined ? e.offsetY : e.pageY - target.offset().top; 260 | mouse.startX = mouse.lastX = e.pageX; 261 | mouse.startY = mouse.lastY = e.pageY; 262 | 263 | this.dragRootEl = this.el; 264 | 265 | this.dragEl = $(document.createElement(this.options.listNodeName)).addClass(this.options.listClass + ' ' + this.options.dragClass); 266 | this.dragEl.css('width', dragItem.width()); 267 | 268 | dragItem.after(this.placeEl); 269 | dragItem[0].parentNode.removeChild(dragItem[0]); 270 | dragItem.appendTo(this.dragEl); 271 | 272 | $(document.body).append(this.dragEl); 273 | this.dragEl.css({ 274 | 'left' : e.pageX - mouse.offsetX, 275 | 'top' : e.pageY - mouse.offsetY 276 | }); 277 | // total depth of dragging item 278 | var i, depth, 279 | items = this.dragEl.find(this.options.itemNodeName); 280 | for (i = 0; i < items.length; i++) { 281 | depth = $(items[i]).parents(this.options.listNodeName).length; 282 | if (depth > this.dragDepth) { 283 | this.dragDepth = depth; 284 | } 285 | } 286 | }, 287 | 288 | dragStop: function(e) 289 | { 290 | var el = this.dragEl.children(this.options.itemNodeName).first(); 291 | el[0].parentNode.removeChild(el[0]); 292 | this.placeEl.replaceWith(el); 293 | 294 | this.dragEl.remove(); 295 | this.el.trigger('change'); 296 | if (this.hasNewRoot) { 297 | this.dragRootEl.trigger('change'); 298 | } 299 | this.reset(); 300 | }, 301 | 302 | dragMove: function(e) 303 | { 304 | var list, parent, prev, next, depth, 305 | opt = this.options, 306 | mouse = this.mouse; 307 | 308 | this.dragEl.css({ 309 | 'left' : e.pageX - mouse.offsetX, 310 | 'top' : e.pageY - mouse.offsetY 311 | }); 312 | 313 | // mouse position last events 314 | mouse.lastX = mouse.nowX; 315 | mouse.lastY = mouse.nowY; 316 | // mouse position this events 317 | mouse.nowX = e.pageX; 318 | mouse.nowY = e.pageY; 319 | // distance mouse moved between events 320 | mouse.distX = mouse.nowX - mouse.lastX; 321 | mouse.distY = mouse.nowY - mouse.lastY; 322 | // direction mouse was moving 323 | mouse.lastDirX = mouse.dirX; 324 | mouse.lastDirY = mouse.dirY; 325 | // direction mouse is now moving (on both axis) 326 | mouse.dirX = mouse.distX === 0 ? 0 : mouse.distX > 0 ? 1 : -1; 327 | mouse.dirY = mouse.distY === 0 ? 0 : mouse.distY > 0 ? 1 : -1; 328 | // axis mouse is now moving on 329 | var newAx = Math.abs(mouse.distX) > Math.abs(mouse.distY) ? 1 : 0; 330 | 331 | // do nothing on first move 332 | if (!mouse.moving) { 333 | mouse.dirAx = newAx; 334 | mouse.moving = true; 335 | return; 336 | } 337 | 338 | // calc distance moved on this axis (and direction) 339 | if (mouse.dirAx !== newAx) { 340 | mouse.distAxX = 0; 341 | mouse.distAxY = 0; 342 | } else { 343 | mouse.distAxX += Math.abs(mouse.distX); 344 | if (mouse.dirX !== 0 && mouse.dirX !== mouse.lastDirX) { 345 | mouse.distAxX = 0; 346 | } 347 | mouse.distAxY += Math.abs(mouse.distY); 348 | if (mouse.dirY !== 0 && mouse.dirY !== mouse.lastDirY) { 349 | mouse.distAxY = 0; 350 | } 351 | } 352 | mouse.dirAx = newAx; 353 | 354 | /** 355 | * move horizontal 356 | */ 357 | if (mouse.dirAx && mouse.distAxX >= opt.threshold) { 358 | // reset move distance on x-axis for new phase 359 | mouse.distAxX = 0; 360 | prev = this.placeEl.prev(opt.itemNodeName); 361 | // increase horizontal level if previous sibling exists and is not collapsed 362 | if (mouse.distX > 0 && prev.length && !prev.hasClass(opt.collapsedClass)) { 363 | // cannot increase level when item above is collapsed 364 | list = prev.find(opt.listNodeName).last(); 365 | // check if depth limit has reached 366 | depth = this.placeEl.parents(opt.listNodeName).length; 367 | if (depth + this.dragDepth <= opt.maxDepth) { 368 | // create new sub-level if one doesn't exist 369 | if (!list.length) { 370 | list = $('<' + opt.listNodeName + '/>').addClass(opt.listClass); 371 | list.append(this.placeEl); 372 | prev.append(list); 373 | this.setParent(prev); 374 | } else { 375 | // else append to next level up 376 | list = prev.children(opt.listNodeName).last(); 377 | list.append(this.placeEl); 378 | } 379 | } 380 | } 381 | // decrease horizontal level 382 | if (mouse.distX < 0) { 383 | // we can't decrease a level if an item preceeds the current one 384 | next = this.placeEl.next(opt.itemNodeName); 385 | if (!next.length) { 386 | parent = this.placeEl.parent(); 387 | this.placeEl.closest(opt.itemNodeName).after(this.placeEl); 388 | if (!parent.children().length) { 389 | this.unsetParent(parent.parent()); 390 | } 391 | } 392 | } 393 | } 394 | 395 | var isEmpty = false; 396 | 397 | // find list item under cursor 398 | if (!hasPointerEvents) { 399 | this.dragEl[0].style.visibility = 'hidden'; 400 | } 401 | this.pointEl = $(document.elementFromPoint(e.pageX - document.body.scrollLeft, e.pageY - (window.pageYOffset || document.documentElement.scrollTop))); 402 | if (!hasPointerEvents) { 403 | this.dragEl[0].style.visibility = 'visible'; 404 | } 405 | if (this.pointEl.hasClass(opt.handleClass)) { 406 | this.pointEl = this.pointEl.parent(opt.itemNodeName); 407 | } 408 | if (this.pointEl.hasClass(opt.emptyClass)) { 409 | isEmpty = true; 410 | } 411 | else if (!this.pointEl.length || !this.pointEl.hasClass(opt.itemClass)) { 412 | return; 413 | } 414 | 415 | // find parent list of item under cursor 416 | var pointElRoot = this.pointEl.closest('.' + opt.rootClass), 417 | isNewRoot = this.dragRootEl.data('nestable-id') !== pointElRoot.data('nestable-id'); 418 | 419 | /** 420 | * move vertical 421 | */ 422 | if (!mouse.dirAx || isNewRoot || isEmpty) { 423 | // check if groups match if dragging over new root 424 | if (isNewRoot && opt.group !== pointElRoot.data('nestable-group')) { 425 | return; 426 | } 427 | // check depth limit 428 | depth = this.dragDepth - 1 + this.pointEl.parents(opt.listNodeName).length; 429 | if (depth > opt.maxDepth) { 430 | return; 431 | } 432 | var before = e.pageY < (this.pointEl.offset().top + this.pointEl.height() / 2); 433 | parent = this.placeEl.parent(); 434 | // if empty create new list to replace empty placeholder 435 | if (isEmpty) { 436 | list = $(document.createElement(opt.listNodeName)).addClass(opt.listClass); 437 | list.append(this.placeEl); 438 | this.pointEl.replaceWith(list); 439 | } 440 | else if (before) { 441 | this.pointEl.before(this.placeEl); 442 | } 443 | else { 444 | this.pointEl.after(this.placeEl); 445 | } 446 | if (!parent.children().length) { 447 | this.unsetParent(parent.parent()); 448 | } 449 | if (!this.dragRootEl.find(opt.itemNodeName).length) { 450 | this.dragRootEl.append('
'); 451 | } 452 | // parent root list has changed 453 | if (isNewRoot) { 454 | this.dragRootEl = pointElRoot; 455 | this.hasNewRoot = this.el[0] !== this.dragRootEl[0]; 456 | } 457 | } 458 | } 459 | 460 | }; 461 | 462 | $.fn.nestable = function(params) 463 | { 464 | var lists = this, 465 | retval = this; 466 | 467 | lists.each(function() 468 | { 469 | var plugin = $(this).data("nestable"); 470 | 471 | if (!plugin) { 472 | $(this).data("nestable", new Plugin(this, params)); 473 | $(this).data("nestable-id", new Date().getTime()); 474 | } else { 475 | if (typeof params === 'string' && typeof plugin[params] === 'function') { 476 | retval = plugin[params](); 477 | } 478 | } 479 | }); 480 | 481 | return retval || lists; 482 | }; 483 | 484 | })(window.jQuery || window.Zepto, window, document); 485 | --------------------------------------------------------------------------------