├── .editorconfig ├── .gitattributes ├── .gitignore ├── .vscode ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── circle.yml ├── demo ├── index.html ├── package.json ├── postcss.config.js ├── src │ ├── PLATFORM.d.ts │ ├── advanced.html │ ├── advanced.ts │ ├── app.html │ ├── app.ts │ ├── main.ts │ ├── scroll.html │ ├── scroll.ts │ ├── simple-xy.html │ ├── simple-xy.ts │ ├── simple-y.html │ └── simple-y.ts ├── styles.css ├── tsconfig.json └── webpack.config.js ├── docs └── usage.md ├── package-lock.json ├── package.json ├── src ├── auto-scroll.ts ├── optional-parent.ts ├── oribella-aurelia-sortable.ts ├── sortable.ts └── utils.ts ├── test ├── component │ ├── sortable.spec.ts │ └── utils.ts └── setup.ts ├── tsconfig.build.json ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | # 2 space indentation 13 | [**.*] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | demo/* linguist-vendored 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | typings 4 | coverage 5 | .nyc_output 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "typescript.tsdk": "node_modules/typescript/lib", 4 | "search.exclude": { 5 | "**/node_modules": true, 6 | "**/dist": true, 7 | "**/coverage": true, 8 | "**/typings": true, 9 | "**/.nyc_output": true 10 | }, 11 | "tslint.autoFixOnSave": true 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "0.1.0", 5 | "command": "npm", 6 | "args": [ 7 | "run", 8 | "-s" 9 | ], 10 | "isShellCommand": true, 11 | "showOutput": "always", 12 | "suppressTaskName": true, 13 | "tasks": [ 14 | { 15 | "taskName": "build", 16 | "isBuildCommand": true, 17 | "args": [ 18 | "build" 19 | ] 20 | }, 21 | { 22 | "taskName": "test", 23 | "isTestCommand": true, 24 | "args": [ 25 | "test" 26 | ] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-present Christoffer Åström 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Moved to [oribella](https://github.com/oribella/oribella) 2 | 3 | # oribella-aurelia-sortable 4 | 5 | [![npm version](https://badge.fury.io/js/oribella-aurelia-sortable.svg)](https://badge.fury.io/js/oribella-aurelia-sortable) 6 | 7 | [Demo](http://oribella.github.io/aurelia-sortable) 8 | 9 | Sortable plugin for *Aurelia* powered by *Oribella* 10 | 11 | ## Getting started 12 | 13 | To get this plugin up and running and how to use it make sure to read [this](./docs/usage.md#installation). 14 | 15 | ## Building 16 | 17 | ```shell 18 | npm run build 19 | ``` 20 | 21 | ## Tests 22 | 23 | ```shell 24 | npm run test 25 | ``` 26 | 27 | ## Developing 28 | 29 | ```shell 30 | npm run dev; 31 | ``` 32 | 33 | ## Visual Studio Code 34 | 35 | ### Tests 36 | ```shell 37 | ctrl + alt + t 38 | ``` 39 | 40 | ### Building 41 | ```shell 42 | cmd/ctrl + shift + b 43 | ``` 44 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 6.9.1 4 | 5 | dependencies: 6 | post: 7 | - node_modules/.bin/typings install 8 | 9 | test: 10 | post: 11 | - npm run coverage 12 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "MIT", 3 | "devDependencies": { 4 | "aurelia-webpack-plugin": "aurelia/webpack-plugin#v2.0", 5 | "awesome-typescript-loader": "^3.0.0-beta.18", 6 | "bundle-loader": "^0.5.4", 7 | "compression-webpack-plugin": "^0.3.2", 8 | "css-loader": "^0.26.1", 9 | "extract-text-webpack-plugin": "^2.0.0-rc.3", 10 | "html-loader": "^0.4.4", 11 | "html-webpack-plugin": "^2.28.0", 12 | "less": "^2.7.2", 13 | "less-loader": "^2.2.3", 14 | "postcss-cssnext": "^2.9.0", 15 | "postcss-loader": "^1.3.0", 16 | "source-map-loader": "^0.1.6", 17 | "style-loader": "^0.13.1", 18 | "tslib": "^1.5.0", 19 | "typescript": "^2.2", 20 | "webpack": "^2.2", 21 | "webpack-dev-server": "^2.2" 22 | }, 23 | "dependencies": { 24 | "aurelia-bootstrapper": "^2.0.1", 25 | "aurelia-loader-webpack": "^2.0.0", 26 | "oribella": "^0.8.1", 27 | "oribella-framework": "^0.10.1" 28 | }, 29 | "scripts": { 30 | "dev": "webpack-dev-server --host 0.0.0.0", 31 | "build": "webpack -p" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /demo/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-cssnext')({ /* ...options */ }) 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /demo/src/PLATFORM.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace PLATFORM { 2 | function moduleName(module: string, chunk?: string): string; 3 | } -------------------------------------------------------------------------------- /demo/src/advanced.html: -------------------------------------------------------------------------------- 1 | 33 | -------------------------------------------------------------------------------- /demo/src/advanced.ts: -------------------------------------------------------------------------------- 1 | export class Nested { 2 | public groups = [ 3 | { name: 'Odd', typeFlag: 1, items: [] }, 4 | { name: 'Even', typeFlag: 2, items: [] }, 5 | { 6 | name: 'Numbers', typeFlag: 3, items: Array.from(Array(11), (_, i) => i).map((i) => { 7 | return { 8 | name: i === 0 ? 'π' : i, 9 | typeFlag: i % 2 === 0 ? 2 : 1, 10 | lockedFlag: i === 0 ? 3 : 0 11 | }; 12 | }) 13 | } 14 | ]; 15 | } 16 | -------------------------------------------------------------------------------- /demo/src/app.html: -------------------------------------------------------------------------------- 1 | 33 | -------------------------------------------------------------------------------- /demo/src/app.ts: -------------------------------------------------------------------------------- 1 | import { Router, RouterConfiguration } from 'aurelia-router'; 2 | import '../styles.css'; 3 | 4 | export class App { 5 | public router: Router; 6 | 7 | public configureRouter(config: RouterConfiguration, router: Router) { 8 | config.map([ 9 | { route: ['', 'advanced'], name: 'advanced', moduleId: PLATFORM.moduleName('advanced', 'advanced'), nav: true, title: 'Advanced' }, 10 | { route: 'simple-y', name: 'simple-y', moduleId: PLATFORM.moduleName('simple-y'), nav: true, title: 'Simple - Y' }, 11 | { route: 'simple-xy', name: 'simple-xy', moduleId: PLATFORM.moduleName('simple-xy'), nav: true, title: 'Simple - XY' }, 12 | { route: 'scroll', name: 'scroll', moduleId: PLATFORM.moduleName('scroll'), nav: true, title: 'Scroll' } 13 | ]); 14 | this.router = router; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /demo/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Aurelia } from 'aurelia-framework'; 2 | import { PLATFORM } from 'aurelia-pal'; 3 | 4 | export function configure(aurelia: Aurelia) { 5 | aurelia.use 6 | .standardConfiguration() 7 | .developmentLogging() 8 | .plugin(PLATFORM.moduleName('oribella-aurelia-sortable')); 9 | aurelia.start().then(() => aurelia.setRoot(PLATFORM.moduleName('app'))); 10 | } 11 | -------------------------------------------------------------------------------- /demo/src/scroll.html: -------------------------------------------------------------------------------- 1 | 20 | -------------------------------------------------------------------------------- /demo/src/scroll.ts: -------------------------------------------------------------------------------- 1 | export class Scroll { 2 | public items = [{ 3 | name: `Lorem ipsum dolor sit amet, mutat ocurreret voluptaria vis et, alii quodsi incorrupte vis in. Eos debet persius pericula ei, in has posse suscipiantur, ius.`, 4 | img: `http://lorempixel.com/48/48/sports/1` 5 | }, { 6 | name: `Lorem ipsum dolor sit amet, cibo ubique ei vim. Pro an porro repudiare sadipscing, vis at mazim detraxit. Cum et solum adipisci persequeris, ex delectus.`, 7 | img: `http://lorempixel.com/48/48/sports/2` 8 | }, { 9 | name: `Lorem ipsum dolor sit amet, eu pro malis vitae, pro no sumo justo blandit. Doming oblique disputationi ex duo, an.`, 10 | img: `http://lorempixel.com/48/48/sports/3` 11 | }, { 12 | name: `Lorem ipsum dolor sit amet, pri ad tacimates forensibus, aperiri meliore mea cu. Vix lucilius imperdiet democritum ne, ut elitr gubergren eam, per at incorrupte reprehendunt. Mnesarchum dissentiunt vis ne.`, 13 | img: `http://lorempixel.com/48/48/sports/4` 14 | }, { 15 | name: `Lorem ipsum dolor sit amet, augue semper nostrum eam id.`, 16 | img: `http://lorempixel.com/48/48/sports/5` 17 | }, { 18 | name: `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque ac porttitor mauris. Integer et massa arcu. Fusce tincidunt enim a consequat viverra.`, 19 | img: `http://lorempixel.com/48/48/sports/6` 20 | }, { 21 | name: `Lorem ipsum dolor sit amet, velit mollis porta feugiat, sapien ante gravida arcu, ac tincidunt justo ex a ante.`, 22 | img: `http://lorempixel.com/48/48/sports/7` 23 | }]; 24 | } 25 | -------------------------------------------------------------------------------- /demo/src/simple-xy.html: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /demo/src/simple-xy.ts: -------------------------------------------------------------------------------- 1 | export class ContainerScroll { 2 | public items = Array.from(Array(16), (_, i) => i).map((i) => ({ img: `https://github.com/identicons/${i}.png` })); 3 | } 4 | -------------------------------------------------------------------------------- /demo/src/simple-y.html: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /demo/src/simple-y.ts: -------------------------------------------------------------------------------- 1 | export class PageScroll { 2 | public items = [{ 3 | name: `Lorem ipsum dolor sit amet, mutat ocurreret voluptaria vis et, alii quodsi incorrupte vis in. Eos debet persius pericula ei, in has posse suscipiantur, ius.`, 4 | img: `http://lorempixel.com/48/48/sports/1` 5 | }, { 6 | name: `Lorem ipsum dolor sit amet, cibo ubique ei vim. Pro an porro repudiare sadipscing, vis at mazim detraxit. Cum et solum adipisci persequeris, ex delectus.`, 7 | img: `http://lorempixel.com/48/48/sports/2` 8 | }, { 9 | name: `Lorem ipsum dolor sit amet, eu pro malis vitae, pro no sumo justo blandit. Doming oblique disputationi ex duo, an.`, 10 | img: `http://lorempixel.com/48/48/sports/3` 11 | }, { 12 | name: `Lorem ipsum dolor sit amet, pri ad tacimates forensibus, aperiri meliore mea cu. Vix lucilius imperdiet democritum ne, ut elitr gubergren eam, per at incorrupte reprehendunt. Mnesarchum dissentiunt vis ne.`, 13 | img: `http://lorempixel.com/48/48/sports/4` 14 | }, { 15 | name: `Lorem ipsum dolor sit amet, augue semper nostrum eam id.`, 16 | img: `http://lorempixel.com/48/48/sports/5` 17 | }]; 18 | } 19 | -------------------------------------------------------------------------------- /demo/styles.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "LUI icons"; 3 | src: url(https://unpkg.com/leonardo-ui@1.0.2/dist/lui-icons.woff) format('woff'), url(https://unpkg.com/leonardo-ui@1.0.2/dist/lui-icons.ttf) format('truetype'); 4 | } 5 | 6 | @import url(https://fonts.googleapis.com/css?family=Noto+Sans:400,700); 7 | @import url(https://fonts.googleapis.com/css?family=Source+Code+Pro); 8 | html, 9 | body { 10 | margin: 0; 11 | padding: 0; 12 | height: 100%; 13 | } 14 | 15 | body { 16 | font-family: 'Noto Sans', sans-serif; 17 | font-size: 14px; 18 | color: #595959; 19 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 20 | } 21 | 22 | section { 23 | margin: 30px 0 0 0; 24 | } 25 | 26 | section:first-child { 27 | margin-top: 0; 28 | } 29 | 30 | input, 31 | textarea, 32 | select, 33 | button { 34 | font-family: 'Noto Sans', sans-serif; 35 | font-size: 13px; 36 | } 37 | 38 | a:link, 39 | a:active, 40 | a:hover, 41 | a:visited { 42 | color: inherit; 43 | text-decoration: none; 44 | } 45 | 46 | a:hover { 47 | text-decoration: underline; 48 | } 49 | 50 | img { 51 | border: none; 52 | } 53 | 54 | .header-wrapper { 55 | background-color: white; 56 | border-bottom: 1px solid #CCCCCC; 57 | height: 50px; 58 | box-sizing: border-box; 59 | } 60 | 61 | .header { 62 | max-width: 960px; 63 | height: 100%; 64 | margin: 0 auto; 65 | display: flex; 66 | flex-direction: row; 67 | justify-content: space-between; 68 | align-items: center; 69 | } 70 | 71 | .header-links { 72 | display: flex; 73 | } 74 | 75 | .header-logo { 76 | display: flex; 77 | padding: 4px; 78 | } 79 | 80 | .header-logo img { 81 | height: 32px; 82 | } 83 | 84 | .header-logo span { 85 | align-self: center; 86 | padding-left: 5px; 87 | font-size: 16px; 88 | } 89 | 90 | @media (max-width: 380px) { 91 | .header-logo { 92 | display:none; 93 | } 94 | } 95 | 96 | .container { 97 | max-width: 940px; 98 | margin: 0 auto; 99 | padding: 4px; 100 | } 101 | 102 | .container > div { 103 | display: flex; 104 | } 105 | 106 | @media (max-width: 767px) { 107 | .sidebar { 108 | display: none; 109 | } 110 | } 111 | 112 | .content { 113 | flex: 1 1 auto; 114 | max-width: 800px; 115 | } 116 | 117 | .footer { 118 | padding: 30px 0; 119 | min-height: 75px; 120 | border-top: 1px solid #CCCCCC; 121 | text-align: center; 122 | } 123 | 124 | .footer .footer-title { 125 | font-size: 18px; 126 | } 127 | 128 | .wrap { 129 | max-width: 960px; 130 | margin: 0 auto; 131 | } 132 | 133 | * { 134 | user-select: none; 135 | } 136 | 137 | .groups { 138 | width: 248px; 139 | margin: 0 auto; 140 | } 141 | 142 | .group-container { 143 | padding: 4px 0px; 144 | } 145 | 146 | .group { 147 | width: 100%; 148 | padding: 4px; 149 | } 150 | 151 | .lui-list .lui-list__header.lui-disabled { 152 | color: rgba(89, 89, 89, .3); 153 | background-color: transparent; 154 | } 155 | 156 | .lui-icon--handle { 157 | display: flex; 158 | align-items: center; 159 | justify-content: center; 160 | height: 28px; 161 | min-width: 24px; 162 | touch-action: none; 163 | } 164 | 165 | .locked-text { 166 | font-size: 10px; 167 | } 168 | 169 | li { 170 | list-style: none; 171 | } 172 | 173 | .locked:hover, 174 | .trigger-locked { 175 | -webkit-animation: shake 0.82s cubic-bezier(.36, .07, .19, .97) both; 176 | animation: shake 0.82s cubic-bezier(.36, .07, .19, .97) both; 177 | -webkit-transform: translate3d(0, 0, 0); 178 | transform: translate3d(0, 0, 0); 179 | -webkit-backface-visibility: hidden; 180 | backface-visibility: hidden; 181 | -webkit-perspective: 1000px; 182 | perspective: 1000px; 183 | } 184 | 185 | @-webkit-keyframes shake { 186 | 10%, 187 | 90% { 188 | -webkit-transform: translate3d(-1px, 0, 0); 189 | transform: translate3d(-1px, 0, 0); 190 | } 191 | 20%, 192 | 80% { 193 | -webkit-transform: translate3d(2px, 0, 0); 194 | transform: translate3d(2px, 0, 0); 195 | } 196 | 30%, 197 | 50%, 198 | 70% { 199 | -webkit-transform: translate3d(-4px, 0, 0); 200 | transform: translate3d(-4px, 0, 0); 201 | } 202 | 40%, 203 | 60% { 204 | -webkit-transform: translate3d(4px, 0, 0); 205 | transform: translate3d(4px, 0, 0); 206 | } 207 | } 208 | 209 | @keyframes shake { 210 | 10%, 211 | 90% { 212 | -webkit-transform: translate3d(-1px, 0, 0); 213 | transform: translate3d(-1px, 0, 0); 214 | } 215 | 20%, 216 | 80% { 217 | -webkit-transform: translate3d(2px, 0, 0); 218 | transform: translate3d(2px, 0, 0); 219 | } 220 | 30%, 221 | 50%, 222 | 70% { 223 | -webkit-transform: translate3d(-4px, 0, 0); 224 | transform: translate3d(-4px, 0, 0); 225 | } 226 | 40%, 227 | 60% { 228 | -webkit-transform: translate3d(4px, 0, 0); 229 | transform: translate3d(4px, 0, 0); 230 | } 231 | } 232 | 233 | .lui-tabset { 234 | flex: 1; 235 | justify-content: center; 236 | overflow: hidden; 237 | border: 2px solid transparent; 238 | 239 | & .lui-tab:first-child { 240 | border-left: 1px solid rgba(0, 0, 0, 0.1); 241 | } 242 | } 243 | 244 | table { 245 | max-width: 80%; 246 | margin: 0 auto; 247 | border-collapse: collapse; 248 | } 249 | tr { 250 | width: 196px; 251 | border: 1px solid #ccc; 252 | } 253 | 254 | td { 255 | text-align: center; 256 | line-height: 2rem; 257 | padding: 8px; 258 | 259 | & img { 260 | vertical-align: middle; 261 | } 262 | } 263 | 264 | .simple-xy { 265 | display: flex; 266 | align-content: center; 267 | justify-content: center; 268 | flex-flow: wrap; 269 | } 270 | 271 | .simple-xy-item { 272 | position: relative; 273 | flex: 1 0 auto; 274 | box-sizing: border-box; 275 | max-width: 200px; 276 | & img { 277 | width: 184px; 278 | height: 144px; 279 | } 280 | & .lui-icon--handle { 281 | position: absolute; 282 | top: 0; 283 | right: 16px; 284 | font-size: 30px; 285 | } 286 | } 287 | 288 | .oa-sorting { 289 | opacity: 0.3; 290 | } 291 | 292 | .table-container { 293 | max-height: 300px; 294 | overflow: auto; 295 | } 296 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "es6", 5 | "moduleResolution": "node", 6 | "lib": [ "es6", "dom" ], 7 | "importHelpers": true, 8 | 9 | "experimentalDecorators": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /demo/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { AureliaPlugin, ModuleDependenciesPlugin } = require('aurelia-webpack-plugin'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const CompressionPlugin = require('compression-webpack-plugin'); 5 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 6 | const extractCSS = new ExtractTextPlugin('styles/[name].css'); 7 | module.exports = { 8 | entry: { 'main': 'aurelia-bootstrapper' }, 9 | 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: '[name].js', 13 | chunkFilename: '[name].js' 14 | }, 15 | 16 | resolve: { 17 | alias: { 18 | 'oribella-aurelia-sortable$': __dirname + '/../src/oribella-aurelia-sortable.ts', 19 | 'oribella-aurelia-sortable': __dirname + '/../src', 20 | }, 21 | extensions: ['.ts', '.js'], 22 | modules: ['src', 'node_modules'].map(x => path.resolve(x)) 23 | }, 24 | 25 | module: { 26 | rules: [ 27 | { test: /\.ts$/, use: [{ loader: 'awesome-typescript-loader' }] }, 28 | { enforce: 'pre', test: /\.js$/, loader: 'source-map-loader' }, 29 | { test: /\.html$/i, use: 'html-loader' }, 30 | { 31 | test: /\.css$/i, loader: extractCSS.extract({ 32 | fallback: 'style-loader', 33 | use: [ 34 | { loader: 'css-loader', options: { importLoaders: 1 } }, 35 | 'postcss-loader' 36 | ] 37 | }) 38 | } 39 | ] 40 | }, 41 | 42 | plugins: [ 43 | extractCSS, 44 | new CompressionPlugin({ 45 | asset: "[path].gz[query]", 46 | algorithm: "gzip", 47 | test: /\.js$|\.map$/, 48 | threshold: 10240, 49 | minRatio: 0.8 50 | }), 51 | new HtmlWebpackPlugin({ 52 | template: 'index.html' 53 | }), 54 | new AureliaPlugin(), 55 | new ModuleDependenciesPlugin({ 56 | 'aurelia-bootstrapper': [ 57 | 'aurelia-pal-browser', 58 | { name: 'aurelia-framework', exports: ['Aurelia'] } 59 | ], 60 | 'aurelia-framework': [ 61 | { name: 'aurelia-history-browser', exports: ['configure'] }, 62 | { name: 'aurelia-logging-console', exports: ['configure', 'ConsoleAppender'] }, 63 | { name: 'aurelia-templating-binding', exports: ['configure'] }, 64 | { name: 'aurelia-templating-resources', exports: ['configure'] }, 65 | { name: 'aurelia-templating-router', exports: ['configure'] }, 66 | { name: 'aurelia-event-aggregator', exports: ['configure'] }, 67 | ], 68 | 'aurelia-templating-router': [ 69 | './router-view', 70 | './route-href', 71 | ], 72 | 'aurelia-templating-resources': [ 73 | './compose', 74 | './if', 75 | './with', 76 | './repeat', 77 | './show', 78 | './hide', 79 | './replaceable', 80 | './sanitize-html', 81 | './focus', 82 | './binding-mode-behaviors', 83 | './throttle-binding-behavior', 84 | './debounce-binding-behavior', 85 | './signal-binding-behavior', 86 | './update-trigger-binding-behavior', 87 | './attr-binding-behavior', 88 | ] 89 | }) 90 | ], 91 | devtool: 'source-map' 92 | }; 93 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | JSPM 4 | 5 | ```javascript 6 | jspm install npm:oribella-aurelia-sortable 7 | ``` 8 | NPM 9 | ```javascript 10 | npm install oribella-aurelia-sortable 11 | ``` 12 | 13 | ## Load the plugin 14 | 15 | ```javascript 16 | export function configure(aurelia) { 17 | aurelia.use 18 | .standardConfiguration() 19 | .developmentLogging() 20 | .plugin("oribella-aurelia-sortable"); 21 | 22 | aurelia.start().then(a => a.setRoot()); 23 | } 24 | ``` 25 | 26 | ## Use the plugin 27 | 28 | *sortable*, *sortable-item* will be available as global *custom attributes*. 29 | 30 | To get started you need to bind the *sortable*, *sortable-item* attributes in conjunction with the *repeat* attribute. 31 | ```html 32 | 35 | ``` 36 | This will enable the plugin to keep track of and move around the items. 37 | 38 | ## scroll 39 | If the `sortable` *custom attribute* is in an area that is scrollable you have to bind either a `selector` or an `Element` or `document`: 40 | ```html 41 | 42 |
43 |
44 | ``` 45 | Default `scroll='document'` 46 | 47 | ## scrollSpeed scrollSensitivity 48 | so it can auto scroll when needed. If you want you may even do a manual scroll when it's auto scrolling or combine them by first auto scrolling then manual scrolling and it should still behave as intended. If you are not happy with the sensitivity or scroll speed for the auto scroll you can set it with below bindings: 49 | ```html 50 | 51 |
52 |
53 | ``` 54 | Default `scrollSpeed=10` - how many pixels it will scroll for each frame 55 | 56 | Default `scrollSensitivity=10` - how many pixels from the `scroll` bounding threshold until it starts auto scrolling. 57 | 58 | ## axis 59 | If you have a vertical or horizontal list you can lock the sortable axis movement with: 60 | ```html 61 | 62 | 63 |
64 |
65 | 66 |
67 |
68 | ``` 69 | Default `axis=3` - sets the allowed axis movement. Which is both horizontal and vertical movement. 70 | 71 | ## dragZIndex 72 | To make sure that the dragging of a *sortable item* always is on top of other elements make sure to bind: 73 | ```html 74 | 75 |
76 |
77 | ``` 78 | Default `dragZIndex=1` - z-index of the dragging *sortable item*. 79 | 80 | ## disallowedDragTagNames 81 | To be able to have a *sortable* where you might be editing the *sortable items* you can control this by: 82 | ```html 83 | 84 |
85 |
86 | ``` 87 | Default `disallowedDragTagNames=['INPUT', 'SELECT', 'TEXTAREA']` - element tags that disallows start dragging. 88 | 89 | ## allowedDragSelector allowedDragSelectors 90 | ```html 91 | 92 |
93 |
94 | ``` 95 | or 96 | ```html 97 | 98 |
99 |
100 | ``` 101 | 102 | ## allowDrag 103 | If that is insufficient: 104 | ```html 105 | 106 |
107 |
108 | ``` 109 | Default `allowDrag` 110 | ```javascript 111 | @bindable public allowDrag = ({ event }: { event: Event, item: SortableItem }) => { 112 | const target = (event.target as HTMLElement); 113 | if (this.allowedDragSelector && 114 | !matchesSelector(target, this.allowedDragSelector)) { 115 | return false; 116 | } 117 | if (this.allowedDragSelectors.length && 118 | this.allowedDragSelectors.filter((selector) => matchesSelector(target, selector)).length === 0) { 119 | return false; 120 | } 121 | if (this.disallowedDragSelectors.filter((selector) => matchesSelector(target, selector)).length !== 0) { 122 | return false; 123 | } 124 | if (target.isContentEditable) { 125 | return false; 126 | } 127 | return true; 128 | } 129 | ``` 130 | where `$event` has `event` and `item` properties. 131 | 132 | `event` - the native DOM event 133 | 134 | `item` - the `sortable-item` view-model 135 | 136 | ## onStop 137 | ```html 138 | 139 |
140 |
141 | ``` 142 | 143 | ## typeFlag 144 | ```html 145 | 146 |
147 |
148 | ``` 149 | You can use an abstract type flag to enable/disable moving between lists. 150 | This is useful for more advanced scenarios i.e multi nested sortables. 151 | Checkout the advanced demo for more insight. 152 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oribella-aurelia-sortable", 3 | "version": "0.13.1", 4 | "description": "Aurelia Sortable plugin powered by Oribella", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/oribella/aurelia-sortable" 8 | }, 9 | "main": "dist/commonjs/oribella-aurelia-sortable.js", 10 | "typings": "dist/commonjs/oribella-aurelia-sortable.d.ts", 11 | "scripts": { 12 | "lint": "tslint -c tslint.json --project tsconfig.json", 13 | "pretest": "npm run lint", 14 | "test": "nyc mocha --recursive --compilers ts:ts-node/register --require test/setup.ts", 15 | "coverage": "nyc report --reporter=text-lcov | coveralls", 16 | "clean": "rimraf dist", 17 | "clean:demo": "rimraf demo/dist", 18 | "compile": "tsc -p tsconfig.build.json", 19 | "commonjs": "npm run compile -- --module commonjs --outDir dist/commonjs", 20 | "amd": "npm run compile -- --module amd --outDir dist/amd", 21 | "system": "npm run compile -- --module system --outDir dist/system", 22 | "es2015": "npm run compile -- --module es2015 --outDir dist/es2015 --target es2015", 23 | "build": "npm-run-all -n clean --parallel commonjs amd system es2015", 24 | "build:demo": "npm run clean:demo && cd demo && npm run build", 25 | "deploy:demo": "git subtree split --prefix demo/dist -b gh-pages && git push origin gh-pages:gh-pages", 26 | "dev": "cd demo && npm run dev" 27 | }, 28 | "author": "Christoffer Åström", 29 | "license": "MIT", 30 | "files": [ 31 | "dist", 32 | "src" 33 | ], 34 | "jspm": { 35 | "registry": "npm", 36 | "jspmPackage": true, 37 | "main": "index", 38 | "format": "amd", 39 | "directories": { 40 | "dist": "dist/amd" 41 | }, 42 | "peerDependencies": { 43 | "aurelia-dependency-injection": "^1.3.0", 44 | "aurelia-pal": "^1.2.0", 45 | "aurelia-templating": "^1.2.0", 46 | "aurelia-templating-resources": "^1.3.1", 47 | "oribella": "^0.8.1", 48 | "oribella-framework": "^0.10.1", 49 | "tslib": "^1.5.0" 50 | }, 51 | "dependencies": { 52 | "aurelia-dependency-injection": "^1.3.0", 53 | "aurelia-pal": "^1.2.0", 54 | "aurelia-templating": "^1.2.0", 55 | "aurelia-templating-resources": "^1.3.1", 56 | "oribella": "^0.8.1", 57 | "oribella-framework": "^0.10.1", 58 | "tslib": "^1.5.0" 59 | } 60 | }, 61 | "devDependencies": { 62 | "@types/chai": "^4.0.1", 63 | "@types/jsdom": "^11.0.1", 64 | "@types/mocha": "^2.2.41", 65 | "@types/node": "^8.0.10", 66 | "@types/sinon": "^2.3.2", 67 | "@types/sinon-chai": "^2.7.28", 68 | "aurelia-bootstrapper": "^2.0.1", 69 | "aurelia-framework": "^1.0.8", 70 | "aurelia-loader-nodejs": "^1.0.0", 71 | "aurelia-pal-nodejs": "^1.0.0-beta.1.0.0", 72 | "aurelia-polyfills": "^1.1.1", 73 | "aurelia-testing": "^1.0.0-beta.2.0.1", 74 | "chai": "^3.5.0", 75 | "coveralls": "^2.11.15", 76 | "jsdom": "^9.9.1", 77 | "mocha": "^3.2.0", 78 | "npm-run-all": "^4.0.1", 79 | "nyc": "^10.0.0", 80 | "rimraf": "^2.5.4", 81 | "sinon": "^1.17.6", 82 | "sinon-chai": "^2.8.0", 83 | "ts-node": "^2.1.0", 84 | "tslint": "^4.0.2", 85 | "typescript": "^2.2.1", 86 | "typings": "^2.0.0" 87 | }, 88 | "dependencies": { 89 | "aurelia-dependency-injection": "^1.3.0", 90 | "aurelia-pal": "^1.2.0", 91 | "aurelia-templating": "^1.2.0", 92 | "aurelia-templating-resources": "^1.3.1", 93 | "oribella": "^0.8.1", 94 | "oribella-framework": "^0.10.1", 95 | "tslib": "^1.5.0" 96 | }, 97 | "nyc": { 98 | "include": [ 99 | "src/*" 100 | ], 101 | "extension": [ 102 | ".ts" 103 | ], 104 | "exclude": [ 105 | "dist", 106 | "typings" 107 | ], 108 | "reporter": [ 109 | "text-summary", 110 | "html" 111 | ], 112 | "all": true, 113 | "sourceMap": true, 114 | "instrument": true 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/auto-scroll.ts: -------------------------------------------------------------------------------- 1 | import { transient } from 'aurelia-dependency-injection'; 2 | import { ScrollData } from './utils'; 3 | 4 | @transient() 5 | export class AutoScroll { 6 | private rAFId: number = -1; 7 | private active = false; 8 | 9 | public activate({ scrollElement, scrollDirection, scrollFrames, scrollSpeed }: ScrollData) { 10 | if (this.active) { 11 | if (scrollDirection.x === 0 && scrollDirection.y === 0) { 12 | window.cancelAnimationFrame(this.rAFId); 13 | this.active = false; 14 | } 15 | return; 16 | } 17 | if (scrollDirection.x === 0 && scrollDirection.y === 0) { 18 | return; 19 | } 20 | 21 | if (scrollFrames.x === 0 && scrollFrames.y === 0) { 22 | return; 23 | } 24 | 25 | const scrollDeltaX = scrollDirection.x * scrollSpeed; 26 | const scrollDeltaY = scrollDirection.y * scrollSpeed; 27 | 28 | const autoScroll = () => { 29 | 30 | if (!this.active) { 31 | return; 32 | } 33 | if (Math.abs(scrollFrames.x) > 0) { 34 | scrollElement.scrollLeft += scrollDeltaX; 35 | } 36 | if (Math.abs(scrollFrames.y) > 0) { 37 | scrollElement.scrollTop += scrollDeltaY; 38 | } 39 | 40 | --scrollFrames.x; 41 | --scrollFrames.y; 42 | if (scrollFrames.x <= 0 && scrollFrames.y <= 0) { 43 | this.active = false; 44 | return; 45 | } 46 | 47 | this.rAFId = window.requestAnimationFrame(autoScroll); 48 | }; 49 | 50 | this.active = true; 51 | autoScroll(); 52 | } 53 | public deactivate() { 54 | window.cancelAnimationFrame(this.rAFId); 55 | this.active = false; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/optional-parent.ts: -------------------------------------------------------------------------------- 1 | import {resolver, Container} from 'aurelia-dependency-injection'; 2 | 3 | @resolver() 4 | export class OptionalParent { 5 | // tslint:disable-next-line:ban-types 6 | constructor(private key: Function | string) {} 7 | 8 | public get(container: Container) { 9 | if (container.parent && container.parent.hasResolver(this.key, true)) { 10 | return container.parent.get(this.key); 11 | } 12 | return null; 13 | } 14 | 15 | // tslint:disable-next-line:ban-types 16 | public static of(key: Function | string) { 17 | return new OptionalParent(key); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/oribella-aurelia-sortable.ts: -------------------------------------------------------------------------------- 1 | import { FrameworkConfiguration } from 'aurelia-framework'; 2 | import { PLATFORM } from 'aurelia-pal'; 3 | import * as OA from 'oribella'; 4 | import * as AS from './auto-scroll'; 5 | import * as OP from './optional-parent'; 6 | import * as S from './sortable'; 7 | import * as U from './utils'; 8 | 9 | export function configure(config: FrameworkConfiguration) { 10 | config.globalResources(PLATFORM.moduleName('./sortable')); 11 | } 12 | 13 | export { OA, AS, OP, S, U }; 14 | -------------------------------------------------------------------------------- /src/sortable.ts: -------------------------------------------------------------------------------- 1 | import { DOM } from 'aurelia-pal'; 2 | import { customAttribute, bindable } from 'aurelia-templating'; 3 | import { Repeat } from 'aurelia-templating-resources'; 4 | import { inject } from 'aurelia-dependency-injection'; 5 | import { oribella, Swipe, matchesSelector, RETURN_FLAG, Point, DefaultListenerArgs } from 'oribella'; 6 | import { OptionalParent } from './optional-parent'; 7 | import { utils, SortableItemElement, SortableElement, DragClone, Rect, PageScrollOffset, AxisFlag, LockedFlag, MoveFlag, Move, DefaultInvalidMove } from './utils'; 8 | import { AutoScroll } from './auto-scroll'; 9 | 10 | export const SORTABLE = 'oa-sortable'; 11 | export const SORTABLE_ATTR = `[${SORTABLE}]`; 12 | export const SORTABLE_ITEM = 'oa-sortable-item'; 13 | export const SORTABLE_ITEM_ATTR = `[${SORTABLE_ITEM}]`; 14 | 15 | declare module 'aurelia-templating-resources' { 16 | export interface Repeat { 17 | viewFactory: { 18 | isCaching: boolean; 19 | setCacheSize: (first: number | string, second: boolean) => void; 20 | }; 21 | } 22 | } 23 | 24 | @customAttribute(SORTABLE) 25 | @inject(DOM.Element, OptionalParent.of(Sortable), AutoScroll) 26 | export class Sortable { 27 | @bindable public items: any = []; 28 | @bindable public scroll: string | Element = 'document'; 29 | @bindable public scrollSpeed: number = 10; 30 | @bindable public scrollSensitivity: number = 10; 31 | @bindable public axis: string = AxisFlag.XY; 32 | @bindable public onStop: (move: Move) => void = () => { }; 33 | @bindable public sortingClass: string = 'oa-sorting'; 34 | @bindable public dragClass: string = 'oa-drag'; 35 | @bindable public dragZIndex: number = 1; 36 | @bindable public disallowedDragSelectors: string[] = ['INPUT', 'SELECT', 'TEXTAREA']; 37 | @bindable public allowedDragSelector: string = ''; 38 | @bindable public allowedDragSelectors: string[] = []; 39 | @bindable public allowDrag = ({ evt }: { evt: Event, item: SortableItem }) => { 40 | const target = (evt.target as HTMLElement); 41 | if (this.allowedDragSelector && 42 | !matchesSelector(target, this.allowedDragSelector)) { 43 | return false; 44 | } 45 | if (this.allowedDragSelectors.length && 46 | this.allowedDragSelectors.filter((selector) => matchesSelector(target, selector)).length === 0) { 47 | return false; 48 | } 49 | if (this.disallowedDragSelectors.filter((selector) => matchesSelector(target, selector)).length !== 0) { 50 | return false; 51 | } 52 | if (target.isContentEditable) { 53 | return false; 54 | } 55 | return true; 56 | } 57 | @bindable public typeFlag: number = 1; 58 | 59 | public dragClone: DragClone = { 60 | parent: window.document.body, 61 | viewModel: null, 62 | element: null, 63 | offset: new Point(0, 0), 64 | position: new Point(0, 0), 65 | width: 0, 66 | height: 0, 67 | display: null 68 | }; 69 | public sortableDepth: number = -1; 70 | public isDisabled: boolean = false; 71 | public selector: string = SORTABLE_ITEM_ATTR; 72 | 73 | private scrollListener: Element | Document; 74 | private removeListener: () => void; 75 | private downClientPoint: Point = new Point(0, 0); 76 | private currentClientPoint: Point = new Point(0, 0); 77 | private boundaryRect: Rect; 78 | private scrollRect: Rect; 79 | private rootSortable: Sortable; 80 | private rootSortableRect: Rect; 81 | private childSortables: Sortable[] = []; 82 | private lastElementFromPointRect: Rect; 83 | private target: Element; 84 | private move: Move = DefaultInvalidMove; 85 | 86 | constructor(public element: Element, public parentSortable: Sortable, private autoScroll: AutoScroll) { 87 | this.sortableDepth = utils.getSortableDepth(this); 88 | } 89 | 90 | public activate() { 91 | this.removeListener = oribella.on(Swipe, this.element, this as any); 92 | const { scrollElement, scrollListener } = utils.ensureScroll(this.scroll, this.element); 93 | this.scroll = scrollElement; 94 | this.scrollListener = scrollListener; 95 | this.scrollListener.addEventListener('scroll', this as any, false); 96 | } 97 | public deactivate() { 98 | this.removeListener(); 99 | this.scrollListener.removeEventListener('scroll', this as any, false); 100 | } 101 | public handleEvent() { 102 | utils.updateDragClone(this.dragClone, this.currentClientPoint, window, this.axis); 103 | this.tryMove(this.currentClientPoint, window); 104 | } 105 | public attached() { 106 | this.activate(); 107 | } 108 | public detached() { 109 | this.deactivate(); 110 | } 111 | private tryScroll(client: Point) { 112 | const scrollElement = this.scroll as Element; 113 | const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = scrollElement; 114 | const scrollSpeed = this.scrollSpeed; 115 | const scrollMaxPos = utils.getScrollMaxPos(this.element, this.rootSortableRect, scrollElement, { scrollLeft, scrollTop, scrollWidth, scrollHeight }, this.scrollRect, window); 116 | const scrollDirection = utils.getScrollDirection(this.axis, this.scrollSensitivity, client, this.scrollRect); 117 | scrollMaxPos.x = scrollDirection.x === -1 ? 0 : scrollMaxPos.x; 118 | scrollMaxPos.y = scrollDirection.y === -1 ? 0 : scrollMaxPos.y; 119 | const scrollFrames = utils.getScrollFrames(scrollDirection, scrollMaxPos, { scrollLeft, scrollTop }, scrollSpeed); 120 | this.autoScroll.activate({ scrollElement, scrollDirection, scrollFrames, scrollSpeed }); 121 | } 122 | private tryMove(point: Point, scrollOffset: PageScrollOffset) { 123 | if (utils.canThrottle(this.lastElementFromPointRect, point, scrollOffset)) { 124 | return; 125 | } 126 | const element = utils.elementFromPoint(point, this.selector, this.element, this.dragClone, this.axis); 127 | if (!element) { 128 | return; 129 | } 130 | const vm = utils.getViewModel(element as SortableItemElement); 131 | this.move = utils.move(this.dragClone, vm); 132 | if (this.move.flag === MoveFlag.Valid) { 133 | this.lastElementFromPointRect = element.getBoundingClientRect(); 134 | } 135 | if (this.move.flag === MoveFlag.ValidNewList) { 136 | this.lastElementFromPointRect = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }; 137 | } 138 | } 139 | private trySort(point: Point, scrollOffset: PageScrollOffset) { 140 | utils.hideDragClone(this.dragClone); 141 | this.tryMove(point, scrollOffset); 142 | utils.showDragClone(this.dragClone); 143 | } 144 | private isLockedFrom(fromVM: SortableItem): boolean { 145 | return typeof fromVM.lockedFlag === 'number' && (fromVM.lockedFlag & LockedFlag.From) !== 0; 146 | } 147 | private isClosestSortable(target: Node): boolean { 148 | const closest = utils.closest(target, SORTABLE_ATTR, window.document); 149 | return closest === this.element; 150 | } 151 | private initDragState(client: Point, element: Element, fromVM: SortableItem) { 152 | this.downClientPoint = client; 153 | this.scrollRect = (this.scroll as Element).getBoundingClientRect(); 154 | this.rootSortable = utils.getRootSortable(this); 155 | this.rootSortableRect = this.rootSortable.element.getBoundingClientRect(); 156 | this.childSortables = utils.getChildSortables(this.rootSortable); 157 | this.childSortables.forEach((s) => s.isDisabled = (this.sortableDepth !== s.sortableDepth || (fromVM.typeFlag & s.typeFlag) === 0)); 158 | this.boundaryRect = utils.getBoundaryRect(this.rootSortableRect, window); 159 | this.lastElementFromPointRect = element.getBoundingClientRect(); 160 | } 161 | public down({ evt, data: { pointers: [{ client }] }, target }: DefaultListenerArgs) { 162 | this.move = DefaultInvalidMove; 163 | if (!this.isClosestSortable(evt.target as Node)) { 164 | return RETURN_FLAG.REMOVE; 165 | } 166 | const fromVM = utils.getViewModel(target as SortableItemElement); 167 | const item = fromVM.item; 168 | if (!this.isLockedFrom(fromVM) && this.allowDrag({ evt, item })) { 169 | evt.preventDefault(); 170 | this.target = target; 171 | this.initDragState(client, target, fromVM); 172 | return RETURN_FLAG.IDLE; 173 | } 174 | return RETURN_FLAG.REMOVE; 175 | } 176 | public start({ data: { pointers: [{ client }] }, target }: DefaultListenerArgs) { 177 | utils.addDragClone(this.dragClone, this.element as HTMLElement, this.scroll as Element, target as HTMLElement, this.downClientPoint, this.dragZIndex, this.dragClass, window); 178 | this.target.classList.add(this.sortingClass); 179 | this.tryScroll(client); 180 | } 181 | public update({ data: { pointers: [{ client }] } }: DefaultListenerArgs) { 182 | this.currentClientPoint = client; 183 | utils.updateDragClone(this.dragClone, client, window, this.axis); 184 | this.trySort(client, window); 185 | this.tryScroll(client); 186 | } 187 | public stop() { 188 | if (this.target) { 189 | this.target.classList.remove(this.sortingClass); 190 | } 191 | utils.removeDragClone(this.dragClone); 192 | this.autoScroll.deactivate(); 193 | this.childSortables.forEach((s) => s.isDisabled = false); 194 | this.onStop(this.move); 195 | } 196 | } 197 | 198 | @customAttribute(SORTABLE_ITEM) 199 | @inject(DOM.Element, Repeat) 200 | export class SortableItem { 201 | @bindable public item: any = null; 202 | @bindable public typeFlag: number = 1; 203 | @bindable public lockedFlag: number = 0; 204 | public parentSortable: Sortable | null; 205 | public childSortable: Sortable | null; 206 | 207 | constructor(public element: Element, repeat: Repeat) { 208 | if (!repeat.viewFactory.isCaching) { 209 | repeat.viewFactory.setCacheSize('*', true); 210 | } 211 | } 212 | private getParentSortable(): Sortable | null { 213 | const parent = utils.closest((this.element as Node).parentNode, SORTABLE_ATTR, window.document); 214 | return parent && (parent as SortableElement).au[SORTABLE].viewModel; 215 | } 216 | private getChildSortable(): Sortable | null { 217 | const child = this.element.querySelector(SORTABLE_ATTR); 218 | return child && (child as SortableElement).au[SORTABLE].viewModel; 219 | } 220 | public attached() { 221 | this.parentSortable = this.getParentSortable(); 222 | this.childSortable = this.getChildSortable(); 223 | } 224 | get lockedFrom() { 225 | return (this.lockedFlag & LockedFlag.From) !== 0; 226 | } 227 | get lockedTo() { 228 | return (this.lockedFlag & LockedFlag.To) !== 0; 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Point, matchesSelector } from 'oribella-framework'; 2 | import { Sortable, SortableItem, SORTABLE, SORTABLE_ITEM, SORTABLE_ATTR } from './sortable'; 3 | 4 | export type SortableItemElement = HTMLElement & { au: { [index: string]: { viewModel: SortableItem } } }; 5 | export type SortableElement = HTMLElement & { au: { [index: string]: { viewModel: Sortable } } }; 6 | 7 | // tslint:disable-next-line:no-empty-interface 8 | export interface AxisFlag { 9 | } 10 | export const AxisFlag = { 11 | X: 'x' as 'x', 12 | Y: 'y' as 'y', 13 | XY: '' as '' 14 | }; 15 | 16 | export enum LockedFlag { 17 | From = 1, 18 | To = 2, 19 | FromTo = 3 20 | } 21 | 22 | export enum MoveFlag { 23 | Invalid = 0, 24 | Valid = 1, 25 | ValidNewList = 2 26 | } 27 | 28 | export interface Move { 29 | flag: MoveFlag; 30 | fromItems: any[]; 31 | fromItem: SortableItem | null; 32 | fromIndex: number; 33 | toItems: any[]; 34 | toIndex: number; 35 | } 36 | 37 | export const DefaultInvalidMove = { flag: MoveFlag.Invalid, fromItems: [], fromItem: null, fromIndex: -1, toItems: [], toIndex: -1 }; 38 | 39 | export interface WindowDimension { 40 | innerWidth: number; 41 | innerHeight: number; 42 | } 43 | 44 | export interface Rect { 45 | left: number; 46 | top: number; 47 | right: number; 48 | bottom: number; 49 | width: number; 50 | height: number; 51 | } 52 | 53 | export interface PageScrollOffset { 54 | pageXOffset: number; 55 | pageYOffset: number; 56 | } 57 | 58 | export interface ScrollOffset { 59 | scrollLeft: number; 60 | scrollTop: number; 61 | } 62 | 63 | export interface ScrollRect { 64 | scrollLeft: number; 65 | scrollTop: number; 66 | scrollWidth: number; 67 | scrollHeight: number; 68 | } 69 | 70 | export interface ScrollDirection { 71 | x: number; 72 | y: number; 73 | } 74 | 75 | export interface ScrollDimension { 76 | scrollWidth: number; 77 | scrollHeight: number; 78 | } 79 | 80 | export interface ScrollFrames { 81 | x: number; 82 | y: number; 83 | } 84 | 85 | export interface ScrollData { 86 | scrollElement: Element; 87 | scrollDirection: ScrollDirection; 88 | scrollFrames: ScrollFrames; 89 | scrollSpeed: number; 90 | } 91 | 92 | export interface DragClone { 93 | parent: HTMLElement; 94 | viewModel: SortableItem | null; 95 | element: HTMLElement | null; 96 | offset: Point; 97 | position: Point; 98 | width: number; 99 | height: number; 100 | display: string | null; 101 | }; 102 | 103 | export const utils = { 104 | hideDragClone(dragClone: DragClone) { 105 | const element = dragClone.element as HTMLElement; 106 | dragClone.display = element.style.display; 107 | element.style.display = 'none'; 108 | }, 109 | showDragClone(dragClone: DragClone) { 110 | const element = dragClone.element as HTMLElement; 111 | element.style.display = dragClone.display; 112 | }, 113 | closest(node: Node | null, selector: string, rootNode: Node) { 114 | while (node && node !== rootNode && node !== document) { 115 | if (matchesSelector(node, selector)) { 116 | return node; 117 | } 118 | node = node.parentNode; 119 | } 120 | return null; 121 | }, 122 | getViewModel(element: SortableItemElement): SortableItem { 123 | return element.au[SORTABLE_ITEM].viewModel; 124 | }, 125 | move(dragClone: DragClone, toVM: SortableItem): Move { 126 | let changedToSortable = false; 127 | const fromVM = dragClone.viewModel; 128 | if (!fromVM) { 129 | return DefaultInvalidMove; 130 | } 131 | if (typeof toVM.lockedFlag === 'number' && (toVM.lockedFlag & LockedFlag.To) !== 0) { 132 | return DefaultInvalidMove; 133 | } 134 | const fromSortable = fromVM.parentSortable; 135 | if (!fromSortable) { 136 | return DefaultInvalidMove; 137 | } 138 | let toSortable = toVM.parentSortable; 139 | if (!toSortable) { 140 | return DefaultInvalidMove; 141 | } 142 | const fromItem = fromVM.item; 143 | const toItem = toVM.item; 144 | if (toVM.childSortable && fromSortable.sortableDepth !== toSortable.sortableDepth) { 145 | if (fromSortable.sortableDepth !== toVM.childSortable.sortableDepth) { 146 | return DefaultInvalidMove; 147 | } 148 | toSortable = toVM.childSortable; 149 | changedToSortable = true; 150 | } 151 | if (fromVM.parentSortable !== toSortable && (fromVM.typeFlag & toSortable.typeFlag) === 0) { 152 | return DefaultInvalidMove; 153 | } 154 | if (fromSortable.sortableDepth !== toSortable.sortableDepth) { 155 | return DefaultInvalidMove; 156 | } 157 | const fromItems = fromSortable.items; 158 | const fromIndex = fromItems.indexOf(fromItem); 159 | const toItems = toSortable.items; 160 | let toIndex = toItems.indexOf(toItem); 161 | if (toIndex === -1) { 162 | toIndex = 0; 163 | } 164 | const removedFromItem = fromItems.splice(fromIndex, 1)[0]; 165 | toItems.splice(toIndex, 0, removedFromItem); 166 | if (changedToSortable) { 167 | fromVM.parentSortable = toSortable; 168 | return { flag: MoveFlag.ValidNewList, fromItems, fromItem, fromIndex, toItems, toIndex }; 169 | } 170 | return { flag: MoveFlag.Valid, fromItems, fromItem, fromIndex, toItems, toIndex }; 171 | }, 172 | pointInside({ top, right, bottom, left }: Rect, { x, y }: Point) { 173 | return x >= left && 174 | x <= right && 175 | y >= top && 176 | y <= bottom; 177 | }, 178 | elementFromPoint({ x, y }: Point, selector: string, sortableElement: Element, dragClone: DragClone, axisFlag: AxisFlag) { 179 | if (axisFlag === AxisFlag.X) { 180 | y = dragClone.position.y + dragClone.height / 2; 181 | } 182 | if (axisFlag === AxisFlag.Y) { 183 | x = dragClone.position.x + dragClone.width / 2; 184 | } 185 | let element = document.elementFromPoint(x, y); 186 | if (!element) { 187 | return null; 188 | } 189 | element = utils.closest(element, selector, sortableElement) as Element; 190 | if (!element) { 191 | return null; 192 | } 193 | return element; 194 | }, 195 | canThrottle(lastElementFromPointRect: Rect, { x, y }: Point, { pageXOffset, pageYOffset }: PageScrollOffset) { 196 | return lastElementFromPointRect && 197 | utils.pointInside(lastElementFromPointRect, { x: x + pageXOffset, y: y + pageYOffset } as Point); 198 | }, 199 | addDragClone(dragClone: DragClone, sortableElement: HTMLElement, scrollElement: Element, target: HTMLElement, client: Point, dragZIndex: number, dragClass: string, { pageXOffset, pageYOffset }: PageScrollOffset) { 200 | const targetRect = target.getBoundingClientRect(); 201 | const offset = { left: 0, top: 0 }; 202 | if (sortableElement.contains(scrollElement)) { 203 | while (sortableElement.offsetParent) { 204 | const offsetParentRect = sortableElement.offsetParent.getBoundingClientRect(); 205 | offset.left += offsetParentRect.left; 206 | offset.top += offsetParentRect.top; 207 | sortableElement = sortableElement.offsetParent as HTMLElement; 208 | } 209 | } 210 | dragClone.width = targetRect.width; 211 | dragClone.height = targetRect.height; 212 | dragClone.viewModel = utils.getViewModel(target as SortableItemElement); 213 | dragClone.element = target.cloneNode(true) as HTMLElement; 214 | dragClone.element.style.position = 'absolute'; 215 | dragClone.element.style.width = targetRect.width + 'px'; 216 | dragClone.element.style.height = targetRect.height + 'px'; 217 | dragClone.element.style.pointerEvents = 'none'; 218 | dragClone.element.style.margin = 0 + ''; 219 | dragClone.element.style.zIndex = dragZIndex + ''; 220 | dragClone.element.classList.add(dragClass); 221 | dragClone.position.x = targetRect.left + pageXOffset - offset.left; 222 | dragClone.position.y = targetRect.top + pageYOffset - offset.top; 223 | dragClone.offset.x = dragClone.position.x - client.x - pageXOffset; 224 | dragClone.offset.y = dragClone.position.y - client.y - pageYOffset; 225 | dragClone.element.style.left = dragClone.position.x + 'px'; 226 | dragClone.element.style.top = dragClone.position.y + 'px'; 227 | dragClone.parent.appendChild(dragClone.element); 228 | }, 229 | updateDragClone(dragClone: DragClone, currentClientPoint: Point, { pageXOffset, pageYOffset }: PageScrollOffset, axisFlag: string) { 230 | if (!dragClone.element) { 231 | return; 232 | } 233 | if (axisFlag === AxisFlag.X || axisFlag === AxisFlag.XY) { 234 | dragClone.position.x = currentClientPoint.x + dragClone.offset.x + pageXOffset; 235 | } 236 | if (axisFlag === AxisFlag.Y || axisFlag === AxisFlag.XY) { 237 | dragClone.position.y = currentClientPoint.y + dragClone.offset.y + pageYOffset; 238 | } 239 | 240 | dragClone.element.style.left = dragClone.position.x + 'px'; 241 | dragClone.element.style.top = dragClone.position.y + 'px'; 242 | }, 243 | removeDragClone(dragClone: DragClone) { 244 | if (!dragClone.element) { 245 | return; 246 | } 247 | dragClone.parent.removeChild(dragClone.element); 248 | dragClone.element = null; 249 | dragClone.viewModel = null; 250 | }, 251 | ensureScroll(scroll: string | Element, sortableElement: Element): { scrollElement: Element, scrollListener: Element | Document } { 252 | let scrollElement = sortableElement; 253 | let scrollListener: Element | Document = sortableElement; 254 | if (typeof scroll === 'string') { 255 | if (scroll === 'document') { 256 | scrollElement = document.scrollingElement || document.documentElement || document.body; 257 | scrollListener = document; 258 | } else { 259 | scrollElement = utils.closest(sortableElement, scroll, window.document) as Element; 260 | scrollListener = scrollElement; 261 | } 262 | } 263 | return { scrollElement, scrollListener }; 264 | }, 265 | getBoundaryRect({ left, top, right, bottom }: Rect, { innerWidth, innerHeight }: WindowDimension): Rect { 266 | return { 267 | left: Math.max(0, left), 268 | top: Math.max(0, top), 269 | right: Math.min(innerWidth, right), 270 | bottom: Math.min(innerHeight, bottom), 271 | get width() { 272 | return this.right - this.left; 273 | }, 274 | get height() { 275 | return this.bottom - this.top; 276 | } 277 | }; 278 | }, 279 | getScrollDirection(axisFlag: string, scrollSensitivity: number, { x, y }: Point, { left, top, right, bottom }: Rect): ScrollDirection { 280 | const direction: ScrollDirection = { x: 0, y: 0 }; 281 | if (x >= right - scrollSensitivity) { 282 | direction.x = 1; 283 | } else if (x <= left + scrollSensitivity) { 284 | direction.x = -1; 285 | } 286 | if (y >= bottom - scrollSensitivity) { 287 | direction.y = 1; 288 | } else if (y <= top + scrollSensitivity) { 289 | direction.y = -1; 290 | } 291 | if (axisFlag === AxisFlag.X) { 292 | direction.y = 0; 293 | } 294 | if (axisFlag === AxisFlag.Y) { 295 | direction.x = 0; 296 | } 297 | return direction; 298 | }, 299 | getScrollMaxPos(sortableElement: Element, sortableRect: Rect, scrollElement: Element, { scrollLeft, scrollTop, scrollWidth, scrollHeight }: ScrollRect, scrollRect: Rect, { innerWidth, innerHeight }: WindowDimension): Point { 300 | if (sortableElement.contains(scrollElement)) { 301 | return new Point(scrollWidth - scrollRect.width, scrollHeight - scrollRect.height); 302 | } else { 303 | return new Point(sortableRect.right + scrollLeft - innerWidth, sortableRect.bottom + scrollTop - innerHeight); 304 | } 305 | }, 306 | getScrollFrames(direction: ScrollDirection, maxPos: Point, { scrollLeft, scrollTop }: ScrollOffset, scrollSpeed: number): ScrollFrames { 307 | let x = Math.max(0, Math.ceil(Math.abs(maxPos.x - scrollLeft) / scrollSpeed)); 308 | let y = Math.max(0, Math.ceil(Math.abs(maxPos.y - scrollTop) / scrollSpeed)); 309 | if (direction.x === 1 && scrollLeft >= maxPos.x || 310 | direction.x === -1 && scrollLeft === 0) { 311 | x = 0; 312 | } 313 | if (direction.y === 1 && scrollTop >= maxPos.y || 314 | direction.y === -1 && scrollTop === 0) { 315 | y = 0; 316 | } 317 | return new Point(x, y); 318 | }, 319 | getSortableDepth(sortable: Sortable) { 320 | let depth = 0; 321 | while (sortable.parentSortable) { 322 | ++depth; 323 | sortable = sortable.parentSortable; 324 | } 325 | return depth; 326 | }, 327 | getRootSortable(sortable: Sortable) { 328 | while (sortable.parentSortable) { 329 | sortable = sortable.parentSortable; 330 | } 331 | return sortable; 332 | }, 333 | getChildSortables(rootSortable: Sortable) { 334 | const elements = rootSortable.element.querySelectorAll(`${SORTABLE_ATTR}`); 335 | return Array.from(elements).map((e) => (e as SortableElement).au[SORTABLE].viewModel); 336 | } 337 | }; 338 | -------------------------------------------------------------------------------- /test/component/sortable.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { StageComponent } from 'aurelia-testing'; 3 | import { bootstrap } from 'aurelia-bootstrapper'; 4 | import * as path from 'path'; 5 | import { JSDOM } from 'jsdom'; 6 | 7 | describe('Sortable', () => { 8 | let component: any; 9 | const allowedDragSelectors: string[] = ['.drag-handle']; 10 | const groups = [{}]; 11 | const bindings = { 12 | groups, 13 | allowedDragSelectors 14 | }; 15 | const template = ` 16 | 34 | `; 35 | const html = ` 36 | 37 | 38 |
39 |
40 |
41 |
42 |
43 |
44 | 45 | 46 | `; 47 | let document: Document; 48 | let target: Element; 49 | 50 | beforeEach(async () => { 51 | document = (new JSDOM(html)).window.document; 52 | target = document.querySelector('.target') as Element; 53 | if (!target) { 54 | throw new Error(`target not found ${html}`); 55 | } 56 | component = StageComponent 57 | .withResources(path.resolve(__dirname, '../../src/sortable')) 58 | .inView(template) 59 | .boundTo(bindings); 60 | await component.create(bootstrap); 61 | }); 62 | 63 | it('should have bindable items', () => { 64 | expect(component.viewModel.items).to.equal(bindings.groups); 65 | }); 66 | 67 | it('should handle allow selector', () => { 68 | expect(component.viewModel.allowDrag({ evt: { target } })).to.be.true; 69 | }); 70 | 71 | afterEach(() => { 72 | component.dispose(); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /test/component/utils.ts: -------------------------------------------------------------------------------- 1 | import { Point, PointerData } from 'oribella-framework'; 2 | 3 | export function dispatchMouseEvent( 4 | document: Document, 5 | target: Element, 6 | type: string = 'mousedown', 7 | pageX: number = 100, 8 | pageY: number = 100, 9 | clientX: number = 100, 10 | clientY: number = 100, 11 | button: number = 1) { 12 | const evt = document.createEvent('MouseEvents'); 13 | (evt as any).pageX = pageX; 14 | (evt as any).pageY = pageY; 15 | evt.initMouseEvent(type, 16 | true, true, document.defaultView, 0, 0, 0, clientX, clientY, false, false, false, false, button, null); 17 | target.dispatchEvent(evt); 18 | return evt; 19 | } 20 | 21 | export function dispatchTouchEvent( 22 | document: Document, 23 | target: Element, 24 | type: string = 'touchstart', 25 | touches: Array = [{ page: new Point(100, 100), client: new Point(100, 100), identifier: 1 }], 26 | changedTouches: Array = []) { 27 | const evt = document.createEvent('UIEvent') as any; 28 | evt.initUIEvent(type, true, true, window, 0); 29 | evt.altKey = false; 30 | evt.ctrlKey = false; 31 | evt.shiftKey = false; 32 | evt.metaKey = false; 33 | evt.changedTouches = changedTouches.map((p) => { 34 | return { 35 | pageX: p.page.x, 36 | pageY: p.page.y, 37 | clientX: p.client.x, 38 | clientY: p.client.y, 39 | identifier: p.identifier 40 | }; 41 | }); 42 | evt.touches = touches.map((p) => { 43 | return { 44 | pageX: p.page.x, 45 | pageY: p.page.y, 46 | clientX: p.client.x, 47 | clientY: p.client.y, 48 | identifier: p.identifier 49 | }; 50 | }); 51 | target.dispatchEvent(evt); 52 | return evt; 53 | } 54 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import {use} from 'chai'; 2 | import * as sinonChai from 'sinon-chai'; 3 | import 'aurelia-polyfills'; 4 | use(sinonChai); 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "node", 5 | "target": "es5", 6 | "lib": [ 7 | "es2017", 8 | "dom" 9 | ], 10 | "importHelpers": true, 11 | "noImplicitAny": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "strictNullChecks": true, 16 | "declaration": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "experimentalDecorators": true, 19 | "noEmitHelpers": false, 20 | "sourceMap": true 21 | }, 22 | "exclude": [ 23 | ".vscode", 24 | "dist", 25 | "doc", 26 | "node_modules", 27 | "test", 28 | "demo", 29 | "demo/node_modules" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "node", 5 | "target": "es5", 6 | "lib": [ 7 | "es2017", 8 | "dom" 9 | ], 10 | "importHelpers": true, 11 | "noImplicitAny": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "strictNullChecks": true, 16 | "declaration": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "experimentalDecorators": true, 19 | "noEmitHelpers": false, 20 | "sourceMap": true 21 | }, 22 | "exclude": [ 23 | ".vscode", 24 | "dist", 25 | "doc", 26 | "node_modules", 27 | "demo/node_modules" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:latest", 3 | "rules": { 4 | "quotemark": [ 5 | true, 6 | "single" 7 | ], 8 | "object-literal-sort-keys": false, 9 | "ordered-imports": [ 10 | false 11 | ], 12 | "whitespace": [ 13 | true, 14 | "check-branch", 15 | "check-decl", 16 | "check-operator", 17 | "check-module", 18 | "check-separator", 19 | "check-type" 20 | ], 21 | "interface-name": [ 22 | true, 23 | "never-prefix" 24 | ], 25 | "no-shadowed-variable": false, 26 | "no-string-literal": false, 27 | "trailing-comma": [ 28 | false 29 | ], 30 | "member-ordering": [ 31 | "fields-first" 32 | ], 33 | "no-bitwise": false, 34 | "no-conditional-assignment": false, 35 | "max-classes-per-file": [ 36 | 0 37 | ], 38 | "max-line-length": [ 39 | 0 40 | ], 41 | "no-empty": false 42 | } 43 | } 44 | --------------------------------------------------------------------------------