├── .coveralls.yml ├── .gitignore ├── .nvmrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── bower.json ├── circle.yml ├── dist ├── garnish.js ├── garnish.min.js └── garnish.min.js.map ├── gulpfile.js ├── karma.conf.js ├── karma.coverage.conf.js ├── lib └── Base.js ├── package-lock.json ├── package.json ├── src ├── BaseDrag.js ├── CheckboxSelect.js ├── ContextMenu.js ├── CustomSelect.js ├── DisclosureMenu.js ├── Drag.js ├── DragDrop.js ├── DragMove.js ├── DragSort.js ├── EscManager.js ├── Garnish.js ├── HUD.js ├── MenuBtn.js ├── MixedInput.js ├── Modal.js ├── NiceText.js ├── Select.js ├── SelectMenu.js └── ShortcutManager.js └── test ├── CheckboxSelectTest.js ├── GarnishTest.js ├── HUDTest.js └── MenuTest.js /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: circle-ci -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Source/.idea/ 3 | /.idea 4 | node_modules/* 5 | /coverage 6 | bower_components/* 7 | docs/* -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v11.15.0 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Garnish Changelog 2 | 3 | ## 0.1.48 4 | 5 | ### Changed 6 | - Modals now remove their shades from the DOM when destroyed. 7 | - HUDs now remove their containers and shades from the DOM when destroyed. 8 | - Improved ESC key handling for menus. 9 | - Context menus and disclosure menus now trigger `show` and `hide` events. 10 | 11 | ## 0.1.47 12 | 13 | ### Added 14 | - Added `Garnish.DisclosureMenu`, for cases where a menu is used to show/hide _content_, as opposed to acting like a form ``. 15 | 16 | ### Changed 17 | - Renamed `Garnish.Menu` to `Garnish.CustomSelect`. (`Garnish.Menu` still exists as a deprecated alias.) 18 | 19 | ## 0.1.46 20 | 21 | ### Changed 22 | - `Garnish.NiceText` will now submit the closest form when Ctrl/Command + Return is pressed. ([craftcms/cms#7999](https://github.com/craftcms/cms/issues/7999)) 23 | 24 | ## 0.1.45 25 | 26 | ### Fixed 27 | - Fixed a JavaScript error that could occur with `Garnish.Select`. 28 | 29 | ## 0.1.44 30 | 31 | ### Changed 32 | - `Garnish.Select` now prevents the browser from scrolling to newly-focused items unless the focus was given via a keyboard event. ([craftcms/cms#7940](https://github.com/craftcms/cms/issues/7940)) 33 | 34 | ## 0.1.43 35 | 36 | ### Added 37 | - Added `Garnish.NiceText.charsLeftHtml()`, which can be overridden to customize the HTML used to display the remaining allowed characters. ([#8](https://github.com/pixelandtonic/garnishjs/pull/8)) 38 | 39 | ## 0.1.42 40 | 41 | ### Changed 42 | - `Garnish.Menu` options are now selectable via the Space key. 43 | 44 | ## 0.1.41 45 | 46 | ### Removed 47 | - Removed `Garnish.Pill`. ([craftcms/cms#7705](https://github.com/craftcms/cms/issues/7705)) 48 | 49 | ## 0.1.40 50 | 51 | ### Fixed 52 | - Fixed a bug where `Garnish.isCtrlKeyPressed()` would return `false` on Windows browsers if both `ev.ctrlKey` and `ev.altKey` were both `true`. 53 | 54 | ## 0.1.38 55 | 56 | ### Changed 57 | - Improved `Garnish.ShortcutManager`. 58 | 59 | ## 0.1.37 60 | 61 | ### Fixed 62 | - Fixed an issue where `Garnish.MenuBtn` didn’t have the proper ARIA attributes. 63 | 64 | ## 0.1.36 65 | 66 | ### Fixed 67 | - Fixed a “Can’t remove the base layer.” error that could be thrown if a modal, HUD, or menu was hidden before it was shown. 68 | 69 | ## 0.1.35 70 | 71 | ### Added 72 | - Added `Garnish.ShortcutManager`. 73 | - Added the `hideOnEsc` and `hideOnShadeClick` settings to `Garnish.HUD`. 74 | 75 | ### Deprecated 76 | - Deprecated `Garnish.EscManager`. Use `Garnish.ShortcutManager` instead. 77 | 78 | ## 0.1.34 79 | 80 | ### Fixed 81 | - Fixed a bug where drag helpers weren’t getting set to the correct width and height if the dragged element had `box-sizing: border-box`. 82 | 83 | ## 0.1.33 84 | 85 | ### Changed 86 | - `Garnish.Menu` objects now trigger a `show` event when the menu in shown. 87 | 88 | ## 0.1.32 89 | 90 | ### Changed 91 | - It’s now possible to pass a `Garnish.Menu` object as a second argument when creating a new `Garnish.MenuBtn` object. 92 | - Menus no longer close automatically when the trigger element is blurred, if the focus is changed to an input within the menu. 93 | 94 | ## 0.1.31 95 | 96 | ### Fixed 97 | - Fixed a bug where HUDs could be positioned incorrectly when first opened. ([craftcms/cms#5004](https://github.com/craftcms/cms/issues/5004)) 98 | 99 | ## 0.1.30 100 | 101 | ### Fixed 102 | - Fixed a bug where the scroll container could be changed when selecting `Garnish.Select` items, if the scroll container was something besides the window. ([craftcms/cms#3762](https://github.com/craftcms/cms/issues/3762)) 103 | 104 | ## 0.1.29 105 | 106 | ### Fixed 107 | - Fixed a bug where it wasn’t possible to close HUDs on computers with touchscreens. ([craftcms/cms#3343](https://github.com/craftcms/cms/issues/3343)) 108 | 109 | ## 0.1.28 110 | 111 | ### Fixed 112 | - Fixed a bug where elements with `overflow: auto` could not be scrolled while dragging an element over them. ([craftcms/cms#2340](https://github.com/craftcms/cms/issues/2340)) 113 | 114 | ## 0.1.27 115 | 116 | ### Fixed 117 | - Fixed a bug where HUDs could end up at the top of the page if closed and reopened without scrolling or resizing the window. ([craftcms/cms#3220](https://github.com/craftcms/cms/issues/3220)) 118 | 119 | ## 0.1.26 120 | 121 | ### Fixed 122 | - Fixed an infinite loop bug that could occur if a modal had a nested element with a `resize` event listener. 123 | 124 | ## 0.1.25 125 | 126 | ### Fixed 127 | - Fixed a JavaScript error that occurred when removing event listeners via `Garnish.Base::removeListener()` or `removeAllListeners()`. 128 | 129 | ## 0.1.24 130 | 131 | ### Fixed 132 | - Fixed a bug where Garnish.Select instances would try to reestablish window focus on the virtually focused item on self-destruct. ([craftcms/cms#2964](https://github.com/craftcms/cms/issues/2964)) 133 | 134 | ## 0.1.23 135 | 136 | ### Changed 137 | - Menus that are too tall to fit in the current viewport are now scrollable. ([craftcms/cms#2942](https://github.com/craftcms/cms/issues/2942)) 138 | - Menus now reposition themselves as the window is scrolled. 139 | 140 | ### Fixed 141 | - Fixed a bug where HUDs weren’t automatically resizing/repositioning themselves. 142 | 143 | ## 0.1.22 - 2018-04-07 144 | 145 | ### Added 146 | - Added support for class-level events via `Garnish.on()` and `Garnish.off()`. 147 | 148 | ### Changed 149 | - `Garnish.HUD` instances are now accessible via `.data('hud')` on their container element. 150 | 151 | ## 0.1.21 - 2018-03-29 152 | 153 | ### Changed 154 | - `Garnish.Select` will no longer toggle focus on an item when `spacebar` is pressed, if the `shift` key is down. 155 | - `Garnish.Select` will now trigger a `focusItem` event when an item is focused. 156 | - `Garnish.Select` will now keep track of the focused item via a `$focusedItem` property. 157 | - Event handlers registered with `Garnish.addListener()` can now return `false` to cancel the event. 158 | 159 | ## 0.1.20 - 2018-01-20 160 | 161 | ### Fixed 162 | - Fixed a bug where HUDs could get themselves into an infinite repositioning loop. 163 | 164 | ## 0.1.19 - 2017-07-19 165 | 166 | ### General 167 | - Stability improvements. 168 | 169 | ## 0.1.18 - 2017-05-02 170 | 171 | ### Fixed 172 | - Fixed a bug where HUDs where briefly showing up in the top left corner of the window before getting repositioned. 173 | 174 | ## 0.1.17 - 2017-03-21 175 | 176 | ### Fixed 177 | - Fixed a potential infinite HUD resize loop in IE11. 178 | 179 | ## 0.1.16 - 2017-03-14 180 | 181 | ### Fixed 182 | - Fixed a bug where NiceText objects’ staging areas were getting a `&nbps;` entity appended rather than ` `. (AugustMiller) 183 | 184 | ## 0.1.15 - 2017-02-22 185 | 186 | ### Changed 187 | - Modals no longer automatically update their position when they change size. 188 | 189 | ### Fixed 190 | - Fixed a bug where modals would get caught in infinite resize handling loops. 191 | - Fixed a bug where modals could be initialized with the wrong size when fading in. 192 | 193 | ## 0.1.14 - 2017-02-22 194 | 195 | ### Changed 196 | - Modals and HUDs now trigger `updateSizeAndPosition` events when their size/position changes. 197 | 198 | ## 0.1.13 - 2017-02-16 199 | 200 | ### Changed 201 | - Modals now automatically update their position when they change size. 202 | 203 | ## 0.1.12 - 2017-01-30 204 | 205 | ### Fixed 206 | - Fixed a bug where an infinite event loop could be caused when opening an HUD. 207 | 208 | ## 0.1.11 - 2017-01-04 209 | 210 | ### Fixed 211 | - Fixed a “Garnish is not defined” error. 212 | 213 | ## 0.1.10 - 2017-01-04 214 | 215 | ### Changed 216 | - Garnish no longer has jQuery as a Bower dependency. 217 | 218 | ## 0.1.9 - 2016-12-19 219 | 220 | ### Fixed 221 | - Fixed a bug where HUDs weren’t factoring in window/trigger padding when calculating the max size of the HUD alongside the trigger element 222 | 223 | ## 0.1.8 - 2016-12-07 224 | 225 | ### Changed 226 | - Relaxed the dependencies’ version requirements 227 | 228 | ## 0.1.7 - 2016-12-07 229 | 230 | ### Removed 231 | - Removed touch support from `Garnish.BaseDrag` 232 | 233 | ## 0.1.6 - 2016-11-25 234 | 235 | ### Added 236 | - Added touch support to `Garnish.BaseDrag` 237 | 238 | ### Changed 239 | - Updated gulp-sourcemaps dependency to 1.9.1 240 | 241 | ## 0.1.5 - 2016-11-19 242 | 243 | ### Fixed 244 | - HUDs now have their size and position updated on window resize 245 | 246 | ## 0.1.4 - 2016-09-02 247 | 248 | ### Added 249 | - Added bower as an NPM dependency 250 | - Added element-resize-detector 1.1.7 bower dependency 251 | - Added jquery 2.2.1 bower dependency 252 | - Added velocity 1.2.3 bower dependency 253 | - Added jquery-touch-events 1.0.5 bower dependency 254 | - Added gulp docs task for generating documentation with JSDoc 255 | 256 | ### Fixed 257 | - Fixed a bug where the listener on the `click` event on the HUD’s `$shade` was not working properly with a `taphold` event defined on the same element 258 | 259 | ## 0.1.3 - 2016-08-30 260 | 261 | ### Fixed 262 | - Fixed a bug where NiceText wasn’t accounting for trailing newlines when approximating the input height 263 | 264 | ## 0.1.2 - 2016-08-26 265 | 266 | ### Fixed 267 | - Fixed a bug where clicking on a Menu option could hide the menu before the option had a chance to activate 268 | 269 | ## 0.1.1 - 2016-08-25 270 | 271 | ### Fixed 272 | - Improved NiceText’s input height approximation logic 273 | - Fixed a bug where NiceText was not ensuring the associated input was still visible before recalculating its height 274 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Pixel & Tonic 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Garnish 2 | 3 | [](https://github.com/pixelandtonic/garnishjs/releases) 4 | [](https://circleci.com/gh/pixelandtonic/garnishjs) 5 | [](https://coveralls.io/github/pixelandtonic/garnishjs) 6 | [](LICENSE) 7 | 8 | *Garnish UI Toolkit* 9 | 10 | ## Installation 11 | 12 | You can download the latest version of [garnishjs on GitHub](https://github.com/pixelandtonic/garnishjs/releases/latest). 13 | 14 | To install via npm: 15 | 16 | ```bash 17 | npm install garnishjs 18 | ``` 19 | 20 | To install via bower: 21 | 22 | ```bash 23 | bower install garnishjs 24 | ``` 25 | 26 | ## Building 27 | 28 | To build, run `gulp build`. 29 | 30 | Use `-d` or `--dest` options to customize the destination: 31 | 32 | gulp build --dest=/path/to/dest 33 | gulp build -d=/path/to/dest 34 | 35 | Use `-v` or `--version` options to customize the version: 36 | 37 | gulp build --version=1.0.0 38 | gulp build -v=1.0.0 39 | 40 | To watch, run `gulp watch`. 41 | 42 | ## Testing 43 | 44 | To test, run `gulp test`. 45 | 46 | To watch and test, run `gulp watch --test` 47 | 48 | ## UI Elements 49 | ### Disclosure 50 | This element should be used in instances where a trigger button shows or hides content. 51 | 52 | Some possible applications include accordion menus, navigation dropdown menus, etc. 53 | 54 | To create a disclosure element, use a button with the following properties: 55 | - An `aria-controls` attribute referencing the ID of the element to be toggled 56 | - An `aria-haspopup` attribute set to `"true"`. 57 | - An `aria-expanded` attribute set to either `"true"` or `"false"`. 58 | - A `data-disclosure-trigger` attribute is used to find and instantiate the UI element 59 | 60 | 61 | ```html 62 | Open Menu 63 | 64 | 65 | This is the content you want to reveal. 66 | 67 | ``` 68 | #### Positioning 69 | The disclosure container is positioned absolutely with respect to the trigger element. Because of this, both the disclosure container and the trigger need to be contained inside of a relatively positioned wrapper element. 70 | This element needs to have the attribute `data-wrapper`. 71 | 72 | **Note that this is different from the `CustomSelect` element, where dropdowns are positioned relative to the document.** 73 | 74 | You can change the horizontal alignment of the disclosure by adding a `data-align` attribute with one of the following values: `left`, `center`, or `right`. 75 | 76 | ##### Adjusting positioning 77 | You may need to align the disclosure menu positioning to a different element **inside** of the trigger. 78 | 79 | In cases like this add a `data-align-to` attribute to the disclosure menu with the selector of the element you want to align it with. 80 | ## License 81 | 82 | Garnish is available under the [MIT license](LICENSE). 83 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "garnishjs", 3 | "description": "Garnish UI toolkit", 4 | "main": "gulpfile.js", 5 | "authors": [ 6 | "Brandon Kelly" 7 | ], 8 | "license": "MIT", 9 | "keywords": [ 10 | "garnish", 11 | "garnishjs", 12 | "ui", 13 | "toolkit" 14 | ], 15 | "homepage": "https://github.com/pixelandtonic/garnishjs", 16 | "ignore": [ 17 | "**/.*", 18 | "node_modules", 19 | "bower_components", 20 | "test", 21 | "tests" 22 | ], 23 | "dependencies": { 24 | "jquery-touch-events": "^1.0.5", 25 | "velocity": "^1.2.3", 26 | "element-resize-detector": "^1.1.7" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | general: 2 | artifacts: 3 | - "coverage" 4 | dependencies: 5 | override: 6 | - npm install 7 | test: 8 | override: 9 | - ./node_modules/.bin/bower install 10 | - ./node_modules/.bin/gulp build 11 | - ./node_modules/.bin/gulp test 12 | - ./node_modules/.bin/gulp coverage 13 | - cat ./coverage/lcov.info | ./node_modules/.bin/coveralls -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'), 2 | concat = require('gulp-concat'), 3 | insert = require('gulp-insert'), 4 | uglify = require('gulp-uglify'), 5 | watch = require('gulp-watch'), 6 | sourcemaps = require('gulp-sourcemaps'), 7 | notify = require('gulp-notify'), 8 | plumber = require('gulp-plumber'), 9 | util = require('gulp-util'), 10 | yargs = require('yargs'), 11 | jsdoc = require('gulp-jsdoc3'); 12 | 13 | var Server = require('karma').Server; 14 | 15 | var defaultDest = './dist/'; 16 | var docsDest = './docs/'; 17 | 18 | var defaultVersion = '0.1'; 19 | 20 | var buildGlob = [ 21 | 'lib/*.js', 22 | 'src/Garnish.js', 23 | 'src/Base*.js', 24 | 'src/*.js' 25 | ]; 26 | 27 | //error notification settings for plumber 28 | var plumberErrorHandler = function(err) { 29 | 30 | notify.onError({ 31 | title: "Garnish", 32 | message: "Error: <%= error.message %>", 33 | sound: "Beep" 34 | })(err); 35 | 36 | console.log( 'plumber error!' ); 37 | 38 | this.emit('end'); 39 | }; 40 | 41 | gulp.task('build', buildTask); 42 | gulp.task('watch', watchTask); 43 | gulp.task('coverage', coverageTask); 44 | gulp.task('test', ['unittest']); 45 | gulp.task('unittest', unittestTask); 46 | gulp.task('docs', docsTask); 47 | 48 | gulp.task('default', ['build', 'watch']); 49 | 50 | function buildTask() 51 | { 52 | // Allow overriding the dest directory 53 | // > gulp build --dest=/path/to/dest 54 | // > gulp build -d=/path/to/dest 55 | var dest = yargs.argv.dest || yargs.argv.d || defaultDest; 56 | 57 | // Allow overriding the version 58 | // > gulp build --version=1.0.0 59 | // > gulp build -v=1.0.0 60 | var version = yargs.argv.version || yargs.argv.v || defaultVersion; 61 | 62 | var docBlock = "/**\n" + 63 | " * Garnish UI toolkit\n" + 64 | " *\n" + 65 | " * @copyright 2013 Pixel & Tonic, Inc.. All rights reserved.\n" + 66 | " * @author Brandon Kelly \n" + 67 | " * @version " + version + "\n" + 68 | " * @license MIT\n" + 69 | " */\n"; 70 | 71 | var jqueryOpen = "(function($){\n" + 72 | "\n"; 73 | 74 | var jqueryClose = "\n" + 75 | "})(jQuery);\n"; 76 | 77 | return gulp.src(buildGlob, { base: dest }) 78 | .pipe(plumber({ errorHandler: plumberErrorHandler })) 79 | .pipe(sourcemaps.init()) 80 | .pipe(concat('garnish.js')) 81 | .pipe(insert.prepend(jqueryOpen)) 82 | .pipe(insert.append(jqueryClose)) 83 | .pipe(insert.prepend(docBlock)) 84 | .pipe(gulp.dest(dest)) 85 | .pipe(uglify()) 86 | .pipe(concat('garnish.min.js')) 87 | .pipe(insert.prepend(docBlock)) 88 | .pipe(sourcemaps.write('.')) 89 | .pipe(gulp.dest(dest)); 90 | } 91 | 92 | function watchTask() 93 | { 94 | if (util.env.test) { 95 | return gulp.watch(['src/**', 'test/**'], ['build', 'unittest']); 96 | } 97 | 98 | return gulp.watch('src/**', ['build']); 99 | } 100 | 101 | function coverageTask(done) 102 | { 103 | new Server({ 104 | configFile: __dirname + '/karma.coverage.conf.js', 105 | singleRun: true 106 | }, done).start(); 107 | } 108 | 109 | function unittestTask(done) 110 | { 111 | new Server({ 112 | configFile: __dirname + '/karma.conf.js', 113 | singleRun: true 114 | }, done).start(); 115 | } 116 | 117 | function docsTask(cb) 118 | { 119 | var dest = yargs.argv.dest || yargs.argv.d || docsDest; 120 | 121 | gulp 122 | .src(['src/*.js'], {read: false}) 123 | .pipe(jsdoc(cb)); 124 | } 125 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | config.set({ 3 | 4 | browsers: ['Chrome', 'Firefox'], 5 | frameworks: ['browserify', 'jasmine'], 6 | reporters: ['progress', 'kjhtml'], 7 | 8 | preprocessors: { 9 | 'dist/*.js': ['browserify'] 10 | }, 11 | 12 | files: [ 13 | 'bower_components/jquery/dist/jquery.js', 14 | 'bower_components/velocity/velocity.js', 15 | 'bower_components/element-resize-detector/dist/element-resize-detector.js', 16 | 'dist/garnish.js', 17 | 'test/**/*.js' 18 | ], 19 | 20 | browserify: { 21 | debug: true 22 | }, 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /karma.coverage.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | config.set({ 3 | 4 | browsers: ['Firefox'], 5 | frameworks: ['browserify', 'jasmine'], 6 | preprocessors: { 7 | 'dist/garnish.js': ['browserify'] 8 | }, 9 | 10 | files: [ 11 | 'bower_components/jquery/dist/jquery.js', 12 | 'bower_components/velocity/velocity.js', 13 | 'bower_components/element-resize-detector/dist/element-resize-detector.js', 14 | 'dist/garnish.js', 15 | 'test/**/*.js' 16 | ], 17 | 18 | browserify: { 19 | debug: true, 20 | transform: [['browserify-istanbul', { 21 | instrumenterConfig: { 22 | embed: true 23 | } 24 | }]] 25 | }, 26 | 27 | reporters: ['progress', 'coverage'], 28 | 29 | coverageReporter: { 30 | dir: 'coverage/', 31 | reporters: [ 32 | { type: 'html', subdir: 'report-html' }, 33 | { type: 'lcovonly', subdir: '.', file: 'lcov.info' } 34 | ] 35 | } 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /lib/Base.js: -------------------------------------------------------------------------------- 1 | /*! 2 | Base.js, version 1.1a 3 | Copyright 2006-2010, Dean Edwards 4 | License: http://www.opensource.org/licenses/mit-license.php 5 | */ 6 | 7 | var Base = function() { 8 | // dummy 9 | }; 10 | 11 | Base.extend = function(_instance, _static) { // subclass 12 | var extend = Base.prototype.extend; 13 | 14 | // build the prototype 15 | Base._prototyping = true; 16 | var proto = new this; 17 | extend.call(proto, _instance); 18 | proto.base = function() { 19 | // call this method from any other method to invoke that method's ancestor 20 | }; 21 | delete Base._prototyping; 22 | 23 | // create the wrapper for the constructor function 24 | //var constructor = proto.constructor.valueOf(); //-dean 25 | var constructor = proto.constructor; 26 | var klass = proto.constructor = function() { 27 | if (!Base._prototyping) { 28 | if (this._constructing || this.constructor == klass) { // instantiation 29 | this._constructing = true; 30 | constructor.apply(this, arguments); 31 | delete this._constructing; 32 | } else if (arguments[0] != null) { // casting 33 | return (arguments[0].extend || extend).call(arguments[0], proto); 34 | } 35 | } 36 | }; 37 | 38 | // build the class interface 39 | klass.ancestor = this; 40 | klass.extend = this.extend; 41 | klass.forEach = this.forEach; 42 | klass.implement = this.implement; 43 | klass.prototype = proto; 44 | klass.toString = this.toString; 45 | klass.valueOf = function(type) { 46 | //return (type == "object") ? klass : constructor; //-dean 47 | return (type == "object") ? klass : constructor.valueOf(); 48 | }; 49 | extend.call(klass, _static); 50 | // class initialisation 51 | if (typeof klass.init == "function") klass.init(); 52 | return klass; 53 | }; 54 | 55 | Base.prototype = { 56 | extend: function(source, value) { 57 | if (arguments.length > 1) { // extending with a name/value pair 58 | var ancestor = this[source]; 59 | if (ancestor && (typeof value == "function") && // overriding a method? 60 | // the valueOf() comparison is to avoid circular references 61 | (!ancestor.valueOf || ancestor.valueOf() != value.valueOf()) && 62 | /\bbase\b/.test(value)) { 63 | // get the underlying method 64 | var method = value.valueOf(); 65 | // override 66 | value = function() { 67 | var previous = this.base || Base.prototype.base; 68 | this.base = ancestor; 69 | var returnValue = method.apply(this, arguments); 70 | this.base = previous; 71 | return returnValue; 72 | }; 73 | // point to the underlying method 74 | value.valueOf = function(type) { 75 | return (type == "object") ? value : method; 76 | }; 77 | value.toString = Base.toString; 78 | } 79 | this[source] = value; 80 | } else if (source) { // extending with an object literal 81 | var extend = Base.prototype.extend; 82 | // if this object has a customised extend method then use it 83 | if (!Base._prototyping && typeof this != "function") { 84 | extend = this.extend || extend; 85 | } 86 | var proto = {toSource: null}; 87 | // do the "toString" and other methods manually 88 | var hidden = ["constructor", "toString", "valueOf"]; 89 | // if we are prototyping then include the constructor 90 | var i = Base._prototyping ? 0 : 1; 91 | while (key = hidden[i++]) { 92 | if (source[key] != proto[key]) { 93 | extend.call(this, key, source[key]); 94 | } 95 | } 96 | // copy each of the source object's properties to this object 97 | for (var key in source) { 98 | if (!proto[key]) { 99 | var desc = Object.getOwnPropertyDescriptor(source, key); 100 | if (typeof desc.value != typeof undefined) { 101 | // set the value normally in case it's a function that needs to be overwritten 102 | extend.call(this, key, desc.value); 103 | } else { 104 | // set it while maintaining the original descriptor settings 105 | Object.defineProperty(this, key, desc); 106 | } 107 | } 108 | } 109 | } 110 | return this; 111 | } 112 | }; 113 | 114 | // initialise 115 | Base = Base.extend({ 116 | constructor: function() { 117 | this.extend(arguments[0]); 118 | } 119 | }, { 120 | ancestor: Object, 121 | version: "1.1", 122 | 123 | forEach: function(object, block, context) { 124 | for (var key in object) { 125 | if (this.prototype[key] === undefined) { 126 | block.call(context, object[key], key, object); 127 | } 128 | } 129 | }, 130 | 131 | implement: function() { 132 | for (var i = 0; i < arguments.length; i++) { 133 | if (typeof arguments[i] == "function") { 134 | // if it's a function, call it 135 | arguments[i](this.prototype); 136 | } else { 137 | // add the interface using the extend method 138 | this.prototype.extend(arguments[i]); 139 | } 140 | } 141 | return this; 142 | }, 143 | 144 | toString: function() { 145 | return String(this.valueOf()); 146 | } 147 | }); 148 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "garnishjs", 3 | "description": "Garnish UI toolkit", 4 | "main": "gulpfile.js", 5 | "scripts": { 6 | "test": "gulp test", 7 | "build": "gulp build", 8 | "watch": "gulp watch" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/pixelandtonic/Garnish.git" 13 | }, 14 | "keywords": [ 15 | "garnish", 16 | "ui", 17 | "toolkit" 18 | ], 19 | "author": "Brandon Kelly", 20 | "bugs": { 21 | "url": "https://github.com/pixelandtonic/Garnish/issues" 22 | }, 23 | "homepage": "https://github.com/pixelandtonic/Garnish", 24 | "devDependencies": { 25 | "bower": "^1.7.9", 26 | "browserify": "^13.1.0", 27 | "browserify-istanbul": "^0.2.1", 28 | "coveralls": "^2.11.12", 29 | "gulp": "^3.9.1", 30 | "gulp-bower": "0.0.13", 31 | "gulp-concat": "^2.6.0", 32 | "gulp-insert": "^0.5.0", 33 | "gulp-jsdoc3": "^0.3.0", 34 | "gulp-notify": "^2.2.0", 35 | "gulp-plumber": "^1.1.0", 36 | "gulp-sourcemaps": "^1.9.1", 37 | "gulp-uglify": "^1.5.3", 38 | "gulp-util": "^3.0.7", 39 | "gulp-watch": "^4.3.6", 40 | "jasmine-core": "^2.4.1", 41 | "karma": "^1.2.0", 42 | "karma-browserify": "^5.1.0", 43 | "karma-chrome-launcher": "^2.0.0", 44 | "karma-coverage": "^1.1.1", 45 | "karma-firefox-launcher": "^1.0.0", 46 | "karma-jasmine": "^1.0.2", 47 | "karma-jasmine-html-reporter": "^0.2.2", 48 | "karma-requirejs": "^1.0.0", 49 | "requirejs": "^2.2.0", 50 | "watchify": "^3.7.0", 51 | "yargs": "^4.2.0" 52 | }, 53 | "dependencies": {}, 54 | "version": "0.1.48" 55 | } 56 | -------------------------------------------------------------------------------- /src/BaseDrag.js: -------------------------------------------------------------------------------- 1 | /** global: Garnish */ 2 | /** 3 | * Base drag class 4 | * 5 | * Does all the grunt work for manipulating elements via click-and-drag, 6 | * while leaving the actual element manipulation up to a subclass. 7 | */ 8 | Garnish.BaseDrag = Garnish.Base.extend( 9 | { 10 | $items: null, 11 | 12 | dragging: false, 13 | 14 | mousedownX: null, 15 | mousedownY: null, 16 | realMouseX: null, 17 | realMouseY: null, 18 | mouseX: null, 19 | mouseY: null, 20 | mouseDistX: null, 21 | mouseDistY: null, 22 | mouseOffsetX: null, 23 | mouseOffsetY: null, 24 | 25 | $targetItem: null, 26 | 27 | scrollProperty: null, 28 | scrollAxis: null, 29 | scrollDist: null, 30 | scrollProxy: null, 31 | scrollFrame: null, 32 | 33 | _: null, 34 | 35 | /** 36 | * Constructor 37 | * 38 | * @param {object} items Elements that should be draggable right away. (Can be skipped.) 39 | * @param {object} settings Any settings that should override the defaults. 40 | */ 41 | init: function(items, settings) { 42 | // Param mapping 43 | if (typeof settings === 'undefined' && $.isPlainObject(items)) { 44 | // (settings) 45 | settings = items; 46 | items = null; 47 | } 48 | 49 | this.settings = $.extend({}, Garnish.BaseDrag.defaults, settings); 50 | 51 | this.$items = $(); 52 | this._ = {}; 53 | 54 | if (items) { 55 | this.addItems(items); 56 | } 57 | }, 58 | 59 | /** 60 | * Returns whether dragging is allowed right now. 61 | */ 62 | allowDragging: function() { 63 | return true; 64 | }, 65 | 66 | /** 67 | * Start Dragging 68 | */ 69 | startDragging: function() { 70 | this.dragging = true; 71 | this.onDragStart(); 72 | }, 73 | 74 | /** 75 | * Drag 76 | */ 77 | drag: function(didMouseMove) { 78 | if (didMouseMove) { 79 | // Is the mouse up against one of the window edges? 80 | this.drag._scrollProperty = null; 81 | 82 | if (this.settings.axis !== Garnish.X_AXIS) { 83 | // Scrolling up? 84 | this.drag._winScrollTop = Garnish.$win.scrollTop(); 85 | this.drag._minMouseScrollY = this.drag._winScrollTop + Garnish.BaseDrag.windowScrollTargetSize; 86 | 87 | if (this.mouseY < this.drag._minMouseScrollY) { 88 | this.drag._scrollProperty = 'scrollTop'; 89 | this.drag._scrollAxis = 'Y'; 90 | this.drag._scrollDist = Math.round((this.mouseY - this.drag._minMouseScrollY) / 2); 91 | } 92 | else { 93 | // Scrolling down? 94 | this.drag._maxMouseScrollY = this.drag._winScrollTop + Garnish.$win.height() - Garnish.BaseDrag.windowScrollTargetSize; 95 | 96 | if (this.mouseY > this.drag._maxMouseScrollY) { 97 | this.drag._scrollProperty = 'scrollTop'; 98 | this.drag._scrollAxis = 'Y'; 99 | this.drag._scrollDist = Math.round((this.mouseY - this.drag._maxMouseScrollY) / 2); 100 | } 101 | } 102 | } 103 | 104 | if (!this.drag._scrollProperty && this.settings.axis !== Garnish.Y_AXIS) { 105 | // Scrolling left? 106 | this.drag._winScrollLeft = Garnish.$win.scrollLeft(); 107 | this.drag._minMouseScrollX = this.drag._winScrollLeft + Garnish.BaseDrag.windowScrollTargetSize; 108 | 109 | if (this.mouseX < this.drag._minMouseScrollX) { 110 | this.drag._scrollProperty = 'scrollLeft'; 111 | this.drag._scrollAxis = 'X'; 112 | this.drag._scrollDist = Math.round((this.mouseX - this.drag._minMouseScrollX) / 2); 113 | } 114 | else { 115 | // Scrolling right? 116 | this.drag._maxMouseScrollX = this.drag._winScrollLeft + Garnish.$win.width() - Garnish.BaseDrag.windowScrollTargetSize; 117 | 118 | if (this.mouseX > this.drag._maxMouseScrollX) { 119 | this.drag._scrollProperty = 'scrollLeft'; 120 | this.drag._scrollAxis = 'X'; 121 | this.drag._scrollDist = Math.round((this.mouseX - this.drag._maxMouseScrollX) / 2); 122 | } 123 | } 124 | } 125 | 126 | if (this.drag._scrollProperty) { 127 | // Are we starting to scroll now? 128 | if (!this.scrollProperty) { 129 | if (!this.scrollProxy) { 130 | this.scrollProxy = this._scrollWindow.bind(this); 131 | } 132 | 133 | if (this.scrollFrame) { 134 | Garnish.cancelAnimationFrame(this.scrollFrame); 135 | this.scrollFrame = null; 136 | } 137 | 138 | this.scrollFrame = Garnish.requestAnimationFrame(this.scrollProxy); 139 | } 140 | 141 | this.scrollProperty = this.drag._scrollProperty; 142 | this.scrollAxis = this.drag._scrollAxis; 143 | this.scrollDist = this.drag._scrollDist; 144 | } 145 | else { 146 | this._cancelWindowScroll(); 147 | } 148 | } 149 | 150 | this.onDrag(); 151 | }, 152 | 153 | /** 154 | * Stop Dragging 155 | */ 156 | stopDragging: function() { 157 | this.dragging = false; 158 | this.onDragStop(); 159 | 160 | // Clear the scroll animation 161 | this._cancelWindowScroll(); 162 | }, 163 | 164 | /** 165 | * Add Items 166 | * 167 | * @param {object} items Elements that should be draggable. 168 | */ 169 | addItems: function(items) { 170 | items = $.makeArray(items); 171 | 172 | for (var i = 0; i < items.length; i++) { 173 | var item = items[i]; 174 | 175 | // Make sure this element doesn't belong to another dragger 176 | if ($.data(item, 'drag')) { 177 | Garnish.log('Element was added to more than one dragger'); 178 | $.data(item, 'drag').removeItems(item); 179 | } 180 | 181 | // Add the item 182 | $.data(item, 'drag', this); 183 | 184 | // Add the listener 185 | this.addListener(item, 'mousedown', '_handleMouseDown'); 186 | } 187 | 188 | this.$items = this.$items.add(items); 189 | }, 190 | 191 | /** 192 | * Remove Items 193 | * 194 | * @param {object} items Elements that should no longer be draggable. 195 | */ 196 | removeItems: function(items) { 197 | items = $.makeArray(items); 198 | 199 | for (var i = 0; i < items.length; i++) { 200 | var item = items[i]; 201 | 202 | // Make sure we actually know about this item 203 | var index = $.inArray(item, this.$items); 204 | if (index !== -1) { 205 | this._deinitItem(item); 206 | this.$items.splice(index, 1); 207 | } 208 | } 209 | }, 210 | 211 | /** 212 | * Remove All Items 213 | */ 214 | removeAllItems: function() { 215 | for (var i = 0; i < this.$items.length; i++) { 216 | this._deinitItem(this.$items[i]); 217 | } 218 | 219 | this.$items = $(); 220 | }, 221 | 222 | /** 223 | * Destroy 224 | */ 225 | destroy: function() { 226 | this.removeAllItems(); 227 | this.base(); 228 | }, 229 | 230 | // Events 231 | // --------------------------------------------------------------------- 232 | 233 | /** 234 | * On Drag Start 235 | */ 236 | onDragStart: function() { 237 | Garnish.requestAnimationFrame(function() { 238 | this.trigger('dragStart'); 239 | this.settings.onDragStart(); 240 | }.bind(this)); 241 | }, 242 | 243 | /** 244 | * On Drag 245 | */ 246 | onDrag: function() { 247 | Garnish.requestAnimationFrame(function() { 248 | this.trigger('drag'); 249 | this.settings.onDrag(); 250 | }.bind(this)); 251 | }, 252 | 253 | /** 254 | * On Drag Stop 255 | */ 256 | onDragStop: function() { 257 | Garnish.requestAnimationFrame(function() { 258 | this.trigger('dragStop'); 259 | this.settings.onDragStop(); 260 | }.bind(this)); 261 | }, 262 | 263 | // Private methods 264 | // --------------------------------------------------------------------- 265 | 266 | /** 267 | * Handle Mouse Down 268 | */ 269 | _handleMouseDown: function(ev) { 270 | // Ignore right clicks 271 | if (ev.which !== Garnish.PRIMARY_CLICK) { 272 | return; 273 | } 274 | 275 | // Ignore if we already have a target 276 | if (this.$targetItem) { 277 | return; 278 | } 279 | 280 | // Ignore if they didn't actually click on the handle 281 | var $target = $(ev.target), 282 | $handle = this._getItemHandle(ev.currentTarget); 283 | 284 | if (!$target.is($handle) && !$target.closest($handle).length) { 285 | return; 286 | } 287 | 288 | // Make sure the target isn't a button (unless the button is the handle) 289 | if (ev.currentTarget !== ev.target && this.settings.ignoreHandleSelector) { 290 | if ( 291 | $target.is(this.settings.ignoreHandleSelector) || 292 | $target.closest(this.settings.ignoreHandleSelector).length 293 | ) { 294 | return; 295 | } 296 | } 297 | 298 | ev.preventDefault(); 299 | 300 | // Make sure that dragging is allowed right now 301 | if (!this.allowDragging()) { 302 | return; 303 | } 304 | 305 | // Capture the target 306 | this.$targetItem = $(ev.currentTarget); 307 | 308 | // Capture the current mouse position 309 | this.mousedownX = this.mouseX = ev.pageX; 310 | this.mousedownY = this.mouseY = ev.pageY; 311 | 312 | // Capture the difference between the mouse position and the target item's offset 313 | var offset = this.$targetItem.offset(); 314 | this.mouseOffsetX = ev.pageX - offset.left; 315 | this.mouseOffsetY = ev.pageY - offset.top; 316 | 317 | // Listen for mousemove, mouseup 318 | this.addListener(Garnish.$doc, 'mousemove', '_handleMouseMove'); 319 | this.addListener(Garnish.$doc, 'mouseup', '_handleMouseUp'); 320 | }, 321 | 322 | _getItemHandle: function(item) { 323 | if (this.settings.handle) { 324 | if (typeof this.settings.handle === 'object') { 325 | return $(this.settings.handle); 326 | } 327 | 328 | if (typeof this.settings.handle === 'string') { 329 | return $(this.settings.handle, item); 330 | } 331 | 332 | if (typeof this.settings.handle === 'function') { 333 | return $(this.settings.handle(item)); 334 | } 335 | } 336 | 337 | return $(item); 338 | }, 339 | 340 | /** 341 | * Handle Mouse Move 342 | */ 343 | _handleMouseMove: function(ev) { 344 | ev.preventDefault(); 345 | 346 | this.realMouseX = ev.pageX; 347 | this.realMouseY = ev.pageY; 348 | 349 | if (this.settings.axis !== Garnish.Y_AXIS) { 350 | this.mouseX = ev.pageX; 351 | } 352 | 353 | if (this.settings.axis !== Garnish.X_AXIS) { 354 | this.mouseY = ev.pageY; 355 | } 356 | 357 | this.mouseDistX = this.mouseX - this.mousedownX; 358 | this.mouseDistY = this.mouseY - this.mousedownY; 359 | 360 | if (!this.dragging) { 361 | // Has the mouse moved far enough to initiate dragging yet? 362 | this._handleMouseMove._mouseDist = Garnish.getDist(this.mousedownX, this.mousedownY, this.realMouseX, this.realMouseY); 363 | 364 | if (this._handleMouseMove._mouseDist >= Garnish.BaseDrag.minMouseDist) { 365 | this.startDragging(); 366 | } 367 | } 368 | 369 | if (this.dragging) { 370 | this.drag(true); 371 | } 372 | }, 373 | 374 | /** 375 | * Handle Moues Up 376 | */ 377 | _handleMouseUp: function(ev) { 378 | // Unbind the document events 379 | this.removeAllListeners(Garnish.$doc); 380 | 381 | if (this.dragging) { 382 | this.stopDragging(); 383 | } 384 | 385 | this.$targetItem = null; 386 | }, 387 | 388 | /** 389 | * Scroll Window 390 | */ 391 | _scrollWindow: function() { 392 | this._.scrollPos = Garnish.$scrollContainer[this.scrollProperty](); 393 | Garnish.$scrollContainer[this.scrollProperty](this._.scrollPos + this.scrollDist); 394 | 395 | this['mouse' + this.scrollAxis] -= this._.scrollPos - Garnish.$scrollContainer[this.scrollProperty](); 396 | this['realMouse' + this.scrollAxis] = this['mouse' + this.scrollAxis]; 397 | 398 | this.drag(); 399 | 400 | this.scrollFrame = Garnish.requestAnimationFrame(this.scrollProxy); 401 | }, 402 | 403 | /** 404 | * Cancel Window Scroll 405 | */ 406 | _cancelWindowScroll: function() { 407 | if (this.scrollFrame) { 408 | Garnish.cancelAnimationFrame(this.scrollFrame); 409 | this.scrollFrame = null; 410 | } 411 | 412 | this.scrollProperty = null; 413 | this.scrollAxis = null; 414 | this.scrollDist = null; 415 | }, 416 | 417 | /** 418 | * Deinitialize an item. 419 | */ 420 | _deinitItem: function(item) { 421 | this.removeAllListeners(item); 422 | $.removeData(item, 'drag'); 423 | } 424 | }, 425 | { 426 | minMouseDist: 1, 427 | windowScrollTargetSize: 25, 428 | 429 | defaults: { 430 | handle: null, 431 | axis: null, 432 | ignoreHandleSelector: 'input, textarea, button, select, .btn', 433 | 434 | onDragStart: $.noop, 435 | onDrag: $.noop, 436 | onDragStop: $.noop 437 | } 438 | } 439 | ); 440 | -------------------------------------------------------------------------------- /src/CheckboxSelect.js: -------------------------------------------------------------------------------- 1 | /** global: Garnish */ 2 | /** 3 | * Checkbox select class 4 | */ 5 | Garnish.CheckboxSelect = Garnish.Base.extend( 6 | { 7 | $container: null, 8 | $all: null, 9 | $options: null, 10 | 11 | init: function(container) { 12 | this.$container = $(container); 13 | 14 | // Is this already a checkbox select? 15 | if (this.$container.data('checkboxSelect')) { 16 | Garnish.log('Double-instantiating a checkbox select on an element'); 17 | this.$container.data('checkbox-select').destroy(); 18 | } 19 | 20 | this.$container.data('checkboxSelect', this); 21 | 22 | var $checkboxes = this.$container.find('input'); 23 | this.$all = $checkboxes.filter('.all:first'); 24 | this.$options = $checkboxes.not(this.$all); 25 | 26 | this.addListener(this.$all, 'change', 'onAllChange'); 27 | }, 28 | 29 | onAllChange: function() { 30 | var isAllChecked = this.$all.prop('checked'); 31 | 32 | this.$options.prop({ 33 | checked: isAllChecked, 34 | disabled: isAllChecked 35 | }); 36 | }, 37 | 38 | /** 39 | * Destroy 40 | */ 41 | destroy: function() { 42 | this.$container.removeData('checkboxSelect'); 43 | this.base(); 44 | } 45 | } 46 | ); 47 | -------------------------------------------------------------------------------- /src/ContextMenu.js: -------------------------------------------------------------------------------- 1 | /** global: Garnish */ 2 | /** 3 | * Context Menu 4 | */ 5 | Garnish.ContextMenu = Garnish.Base.extend( 6 | { 7 | $target: null, 8 | options: null, 9 | $menu: null, 10 | showingMenu: false, 11 | 12 | /** 13 | * Constructor 14 | */ 15 | init: function(target, options, settings) { 16 | this.$target = $(target); 17 | 18 | // Is this already a context menu target? 19 | if (this.$target.data('contextmenu')) { 20 | Garnish.log('Double-instantiating a context menu on an element'); 21 | this.$target.data('contextmenu').destroy(); 22 | } 23 | 24 | this.$target.data('contextmenu', this); 25 | 26 | this.options = options; 27 | this.setSettings(settings, Garnish.ContextMenu.defaults); 28 | 29 | Garnish.ContextMenu.counter++; 30 | 31 | this.enable(); 32 | }, 33 | 34 | /** 35 | * Build Menu 36 | */ 37 | buildMenu: function() { 38 | this.$menu = $(''); 39 | 40 | var $ul = $('').appendTo(this.$menu); 41 | 42 | for (var i in this.options) { 43 | if (!this.options.hasOwnProperty(i)) { 44 | continue; 45 | } 46 | 47 | var option = this.options[i]; 48 | 49 | if (option === '-') { 50 | // Create a new 51 | $('').appendTo(this.$menu); 52 | $ul = $('').appendTo(this.$menu); 53 | } 54 | else { 55 | var $li = $('').appendTo($ul), 56 | $a = $('' + option.label + '').appendTo($li); 57 | 58 | if (typeof option.onClick === 'function') { 59 | // maintain the current $a and options.onClick variables 60 | (function($a, onClick) { 61 | setTimeout(function() { 62 | $a.mousedown(function(ev) { 63 | this.hideMenu(); 64 | // call the onClick callback, with the scope set to the item, 65 | // and pass it the event with currentTarget set to the item as well 66 | onClick.call(this.currentTarget, $.extend(ev, {currentTarget: this.currentTarget})); 67 | }.bind(this)); 68 | }.bind(this), 1); 69 | }).call(this, $a, option.onClick); 70 | } 71 | } 72 | } 73 | }, 74 | 75 | /** 76 | * Show Menu 77 | */ 78 | showMenu: function(ev) { 79 | // Ignore left mouse clicks 80 | if (ev.type === 'mousedown' && ev.which !== Garnish.SECONDARY_CLICK) { 81 | return; 82 | } 83 | 84 | if (ev.type === 'contextmenu') { 85 | // Prevent the real context menu from showing 86 | ev.preventDefault(); 87 | } 88 | 89 | // Ignore if already showing 90 | if (this.showing && ev.currentTarget === this.currentTarget) { 91 | return; 92 | } 93 | 94 | this.currentTarget = ev.currentTarget; 95 | 96 | if (!this.$menu) { 97 | this.buildMenu(); 98 | } 99 | 100 | this.$menu.appendTo(document.body); 101 | this.$menu.show(); 102 | this.$menu.css({left: ev.pageX + 1, top: ev.pageY - 4}); 103 | 104 | this.showing = true; 105 | this.trigger('show'); 106 | Garnish.shortcutManager.addLayer(); 107 | Garnish.shortcutManager.registerShortcut(Garnish.ESC_KEY, this.hideMenu.bind(this)); 108 | 109 | setTimeout(function() { 110 | this.addListener(Garnish.$doc, 'mousedown', 'hideMenu'); 111 | }.bind(this), 0); 112 | }, 113 | 114 | /** 115 | * Hide Menu 116 | */ 117 | hideMenu: function() { 118 | this.removeListener(Garnish.$doc, 'mousedown'); 119 | this.$menu.hide(); 120 | this.showing = false; 121 | this.trigger('hide'); 122 | Garnish.shortcutManager.removeLayer(); 123 | }, 124 | 125 | /** 126 | * Enable 127 | */ 128 | enable: function() { 129 | this.addListener(this.$target, 'contextmenu,mousedown', 'showMenu'); 130 | }, 131 | 132 | /** 133 | * Disable 134 | */ 135 | disable: function() { 136 | this.removeListener(this.$target, 'contextmenu,mousedown'); 137 | }, 138 | 139 | /** 140 | * Destroy 141 | */ 142 | destroy: function() { 143 | this.$target.removeData('contextmenu'); 144 | this.base(); 145 | } 146 | }, 147 | { 148 | defaults: { 149 | menuClass: 'menu' 150 | }, 151 | counter: 0 152 | } 153 | ); 154 | -------------------------------------------------------------------------------- /src/CustomSelect.js: -------------------------------------------------------------------------------- 1 | /** global: Garnish */ 2 | /** 3 | * Custom Select Menu 4 | */ 5 | Garnish.CustomSelect = Garnish.Base.extend( 6 | { 7 | settings: null, 8 | visible: false, 9 | 10 | $container: null, 11 | $options: null, 12 | $anchor: null, 13 | 14 | menuId: null, 15 | 16 | _windowWidth: null, 17 | _windowHeight: null, 18 | _windowScrollLeft: null, 19 | _windowScrollTop: null, 20 | 21 | _anchorOffset: null, 22 | _anchorWidth: null, 23 | _anchorHeight: null, 24 | _anchorOffsetRight: null, 25 | _anchorOffsetBottom: null, 26 | 27 | _menuWidth: null, 28 | _menuHeight: null, 29 | 30 | /** 31 | * Constructor 32 | */ 33 | init: function (container, settings) { 34 | this.setSettings(settings, Garnish.CustomSelect.defaults); 35 | 36 | this.$container = $(container); 37 | 38 | this.$options = $(); 39 | this.addOptions(this.$container.find('a')); 40 | 41 | // Menu List 42 | this.menuId = 'menu' + this._namespace; 43 | this.$menuList = $('ul', this.$container); 44 | this.$menuList.attr({ 45 | role: 'listbox', 46 | id: this.menuId, 47 | 'aria-hidden': 'true', 48 | }); 49 | 50 | // Deprecated 51 | if (this.settings.attachToElement) { 52 | this.settings.anchor = this.settings.attachToElement; 53 | Garnish.log( 54 | "The 'attachToElement' setting is deprecated. Use 'anchor' instead." 55 | ); 56 | } 57 | 58 | if (this.settings.anchor) { 59 | this.$anchor = $(this.settings.anchor); 60 | } 61 | 62 | // Prevent clicking on the container from hiding the menu 63 | this.addListener(this.$container, 'mousedown', function (ev) { 64 | ev.stopPropagation(); 65 | 66 | if (ev.target.nodeName !== 'INPUT') { 67 | // Prevent this from causing the menu button to blur 68 | ev.preventDefault(); 69 | } 70 | }); 71 | }, 72 | 73 | addOptions: function ($options) { 74 | this.$options = this.$options.add($options); 75 | $options.data('menu', this); 76 | 77 | $options.each( 78 | function (optionKey, option) { 79 | $(option).attr({ 80 | role: 'option', 81 | tabindex: '-1', 82 | id: this.menuId + '-option-' + optionKey, 83 | }); 84 | }.bind(this) 85 | ); 86 | 87 | this.removeAllListeners($options); 88 | this.addListener($options, 'click', function (ev) { 89 | this.selectOption(ev.currentTarget); 90 | }); 91 | }, 92 | 93 | setPositionRelativeToAnchor: function () { 94 | this._windowWidth = Garnish.$win.width(); 95 | this._windowHeight = Garnish.$win.height(); 96 | this._windowScrollLeft = Garnish.$win.scrollLeft(); 97 | this._windowScrollTop = Garnish.$win.scrollTop(); 98 | 99 | this._anchorOffset = this.$anchor.offset(); 100 | this._anchorWidth = this.$anchor.outerWidth(); 101 | this._anchorHeight = this.$anchor.outerHeight(); 102 | this._anchorOffsetRight = this._anchorOffset.left + this._anchorHeight; 103 | this._anchorOffsetBottom = this._anchorOffset.top + this._anchorHeight; 104 | 105 | this.$container.css('minWidth', 0); 106 | this.$container.css( 107 | 'minWidth', 108 | this._anchorWidth - 109 | (this.$container.outerWidth() - this.$container.width()) 110 | ); 111 | 112 | this._menuWidth = this.$container.outerWidth(); 113 | this._menuHeight = this.$container.outerHeight(); 114 | 115 | // Is there room for the menu below the anchor? 116 | var topClearance = this._anchorOffset.top - this._windowScrollTop, 117 | bottomClearance = 118 | this._windowHeight + this._windowScrollTop - this._anchorOffsetBottom; 119 | 120 | if ( 121 | bottomClearance >= this._menuHeight || 122 | (topClearance < this._menuHeight && bottomClearance >= topClearance) 123 | ) { 124 | this.$container.css({ 125 | top: this._anchorOffsetBottom, 126 | maxHeight: bottomClearance - this.settings.windowSpacing, 127 | }); 128 | } else { 129 | this.$container.css({ 130 | top: 131 | this._anchorOffset.top - 132 | Math.min( 133 | this._menuHeight, 134 | topClearance - this.settings.windowSpacing 135 | ), 136 | maxHeight: topClearance - this.settings.windowSpacing, 137 | }); 138 | } 139 | 140 | // Figure out how we're aliging it 141 | var align = this.$container.data('align'); 142 | 143 | if (align !== 'left' && align !== 'center' && align !== 'right') { 144 | align = 'left'; 145 | } 146 | 147 | if (align === 'center') { 148 | this._alignCenter(); 149 | } else { 150 | // Figure out which options are actually possible 151 | var rightClearance = 152 | this._windowWidth + 153 | this._windowScrollLeft - 154 | (this._anchorOffset.left + this._menuWidth), 155 | leftClearance = this._anchorOffsetRight - this._menuWidth; 156 | 157 | if ((align === 'right' && leftClearance >= 0) || rightClearance < 0) { 158 | this._alignRight(); 159 | } else { 160 | this._alignLeft(); 161 | } 162 | } 163 | 164 | delete this._windowWidth; 165 | delete this._windowHeight; 166 | delete this._windowScrollLeft; 167 | delete this._windowScrollTop; 168 | delete this._anchorOffset; 169 | delete this._anchorWidth; 170 | delete this._anchorHeight; 171 | delete this._anchorOffsetRight; 172 | delete this._anchorOffsetBottom; 173 | delete this._menuWidth; 174 | delete this._menuHeight; 175 | }, 176 | 177 | show: function () { 178 | if (this.visible) { 179 | return; 180 | } 181 | 182 | // Move the menu to the end of the DOM 183 | this.$container.appendTo(Garnish.$bod); 184 | 185 | if (this.$anchor) { 186 | this.setPositionRelativeToAnchor(); 187 | } 188 | 189 | this.$container.velocity('stop'); 190 | this.$container.css({ 191 | opacity: 1, 192 | display: 'block', 193 | }); 194 | 195 | this.$menuList.attr('aria-hidden', 'false'); 196 | 197 | Garnish.shortcutManager 198 | .addLayer() 199 | .registerShortcut(Garnish.ESC_KEY, this.hide.bind(this)); 200 | 201 | this.addListener( 202 | Garnish.$scrollContainer, 203 | 'scroll', 204 | 'setPositionRelativeToAnchor' 205 | ); 206 | 207 | this.visible = true; 208 | this.trigger('show'); 209 | }, 210 | 211 | hide: function () { 212 | if (!this.visible) { 213 | return; 214 | } 215 | 216 | this.$menuList.attr('aria-hidden', 'true'); 217 | 218 | this.$container.velocity( 219 | 'fadeOut', 220 | { duration: Garnish.FX_DURATION }, 221 | function () { 222 | this.$container.detach(); 223 | }.bind(this) 224 | ); 225 | 226 | Garnish.shortcutManager.removeLayer(); 227 | this.removeListener(Garnish.$scrollContainer, 'scroll'); 228 | this.visible = false; 229 | this.trigger('hide'); 230 | }, 231 | 232 | selectOption: function (option) { 233 | this.settings.onOptionSelect(option); 234 | this.trigger('optionselect', { selectedOption: option }); 235 | this.hide(); 236 | }, 237 | 238 | _alignLeft: function () { 239 | this.$container.css({ 240 | left: this._anchorOffset.left, 241 | right: 'auto', 242 | }); 243 | }, 244 | 245 | _alignRight: function () { 246 | this.$container.css({ 247 | right: 248 | this._windowWidth - (this._anchorOffset.left + this._anchorWidth), 249 | left: 'auto', 250 | }); 251 | }, 252 | 253 | _alignCenter: function () { 254 | var left = Math.round( 255 | this._anchorOffset.left + this._anchorWidth / 2 - this._menuWidth / 2 256 | ); 257 | 258 | if (left < 0) { 259 | left = 0; 260 | } 261 | 262 | this.$container.css('left', left); 263 | }, 264 | }, 265 | { 266 | defaults: { 267 | anchor: null, 268 | windowSpacing: 5, 269 | onOptionSelect: $.noop, 270 | }, 271 | } 272 | ); 273 | 274 | /** 275 | * @deprecated 276 | */ 277 | Garnish.Menu = Garnish.CustomSelect; 278 | -------------------------------------------------------------------------------- /src/DisclosureMenu.js: -------------------------------------------------------------------------------- 1 | /** global: Garnish */ 2 | /** 3 | * Disclosure Widget 4 | */ 5 | Garnish.DisclosureMenu = Garnish.Base.extend( 6 | { 7 | settings: null, 8 | 9 | $trigger: null, 10 | $container: null, 11 | $alignmentElement: null, 12 | $wrapper: null, 13 | 14 | _windowWidth: null, 15 | _windowHeight: null, 16 | _windowScrollLeft: null, 17 | _windowScrollTop: null, 18 | 19 | _wrapperElementOffset: null, 20 | _alignmentElementOffset: null, 21 | _triggerWidth: null, 22 | _triggerHeight: null, 23 | 24 | _menuWidth: null, 25 | _menuHeight: null, 26 | 27 | /** 28 | * Constructor 29 | */ 30 | init: function (trigger, settings) { 31 | this.setSettings(settings, Garnish.DisclosureMenu.defaults); 32 | 33 | this.$trigger = $(trigger); 34 | var triggerId = this.$trigger.attr('aria-controls'); 35 | this.$container = $("#" + triggerId); 36 | 37 | if (!this.$container) return; /* Exit if no disclosure container is found */ 38 | 39 | // Get and store expanded state from trigger 40 | var expanded = this.$trigger.attr('aria-expanded'); 41 | 42 | // If no expanded state exists on trigger, add for a11y 43 | if (!expanded) { 44 | this.$trigger.attr('aria-expanded', 'false'); 45 | } 46 | 47 | // Capture additional alignment element 48 | var alignmentSelector = this.$container.data('align-to'); 49 | if (alignmentSelector) { 50 | this.$alignmentElement = $(alignmentSelector); 51 | } else { 52 | this.$alignmentElement = this.$trigger; 53 | } 54 | 55 | var wrapper = this.$container.closest('[data-wrapper]'); 56 | if (wrapper) { 57 | this.$wrapper = wrapper; 58 | } 59 | 60 | this.addDisclosureMenuEventListeners(); 61 | }, 62 | 63 | addDisclosureMenuEventListeners: function() { 64 | this.addListener(this.$trigger, 'click', function() { 65 | this.handleTriggerClick(); 66 | }); 67 | 68 | this.addListener(this.$container, 'keydown', function(event) { 69 | this.handleKeypress(event); 70 | }); 71 | 72 | this.addListener(Garnish.$doc, 'mousedown', this.handleMousedown) 73 | }, 74 | 75 | focusElement: function(direction) { 76 | var currentFocus = $(':focus'); 77 | 78 | var focusable = this.$container.find(':focusable'); 79 | 80 | var currentIndex = focusable.index(currentFocus); 81 | var newIndex; 82 | 83 | if (direction === 'prev') { 84 | newIndex = currentIndex - 1; 85 | } else { 86 | newIndex = currentIndex + 1; 87 | } 88 | 89 | if (newIndex >= 0 && newIndex < focusable.length) { 90 | var elementToFocus = focusable[newIndex]; 91 | elementToFocus.focus(); 92 | } 93 | }, 94 | 95 | handleMousedown: function (event) { 96 | var newTarget = event.target; 97 | var triggerButton = $(newTarget).closest('[data-disclosure-trigger]'); 98 | var newTargetIsInsideDisclosure = this.$container.has(newTarget).length > 0; 99 | 100 | // If click target matches trigger element or disclosure child, do nothing 101 | if ($(triggerButton).is(this.$trigger) || newTargetIsInsideDisclosure) { 102 | return; 103 | } 104 | 105 | this.hide(); 106 | }, 107 | 108 | handleKeypress: function(event) { 109 | var keyCode = event.keyCode; 110 | 111 | switch (keyCode) { 112 | case Garnish.RIGHT_KEY: 113 | case Garnish.DOWN_KEY: 114 | event.preventDefault(); 115 | this.focusElement('next'); 116 | break; 117 | case Garnish.LEFT_KEY: 118 | case Garnish.UP_KEY: 119 | event.preventDefault(); 120 | this.focusElement('prev'); 121 | break; 122 | default: 123 | break; 124 | } 125 | }, 126 | 127 | isExpanded: function () { 128 | var isExpanded = this.$trigger.attr('aria-expanded'); 129 | 130 | return isExpanded === 'true'; 131 | }, 132 | 133 | handleTriggerClick: function() { 134 | if (!this.isExpanded()) { 135 | this.show(); 136 | } else { 137 | this.hide(); 138 | } 139 | }, 140 | 141 | show: function () { 142 | if (this.isExpanded()) { 143 | return; 144 | } 145 | 146 | this.setContainerPosition(); 147 | this.addListener( 148 | Garnish.$scrollContainer, 149 | 'scroll', 150 | 'setContainerPosition' 151 | ); 152 | 153 | this.$container.velocity('stop'); 154 | this.$container.css({ 155 | opacity: 1, 156 | display: 'block', 157 | }); 158 | 159 | 160 | // Set ARIA attribute for expanded 161 | this.$trigger.attr('aria-expanded', 'true'); 162 | 163 | // Focus first focusable element 164 | var firstFocusableEl = this.$container.find(':focusable')[0]; 165 | if (firstFocusableEl) { 166 | firstFocusableEl.focus(); 167 | } else { 168 | this.$container.attr('tabindex', '-1'); 169 | this.$container.focus(); 170 | } 171 | 172 | this.trigger('show'); 173 | Garnish.shortcutManager.addLayer(); 174 | Garnish.shortcutManager.registerShortcut(Garnish.ESC_KEY, function() { 175 | this.hide(); 176 | this.$trigger.focus(); 177 | }.bind(this)); 178 | }, 179 | 180 | hide: function () { 181 | if (!this.isExpanded()) { 182 | return; 183 | } 184 | 185 | this.$container.velocity( 186 | 'fadeOut', 187 | { duration: Garnish.FX_DURATION } 188 | ); 189 | 190 | this.$trigger.attr('aria-expanded', 'false'); 191 | 192 | this.trigger('hide'); 193 | Garnish.shortcutManager.removeLayer(); 194 | }, 195 | 196 | setContainerPosition: function () { 197 | this._windowWidth = Garnish.$win.width(); 198 | this._windowHeight = Garnish.$win.height(); 199 | this._windowScrollLeft = Garnish.$win.scrollLeft(); 200 | this._windowScrollTop = Garnish.$win.scrollTop(); 201 | 202 | this._alignmentElementOffset = this.$alignmentElement[0].getBoundingClientRect(); 203 | 204 | this._wrapperElementOffset = this.$wrapper[0].getBoundingClientRect(); 205 | 206 | this._triggerWidth = this.$trigger.outerWidth(); 207 | 208 | this.$container.css('minWidth', 0); 209 | this.$container.css( 210 | 'minWidth', 211 | this._triggerWidth - 212 | (this.$container.outerWidth() - this.$container.width()) 213 | ); 214 | 215 | this._menuWidth = this.$container.outerWidth(); 216 | this._menuHeight = this.$container.outerHeight(); 217 | 218 | // Is there room for the menu below the trigger? 219 | var topClearance = this._alignmentElementOffset.top, 220 | bottomClearance = this._windowHeight - this._alignmentElementOffset.bottom; 221 | 222 | // Find top/bottom offset relative to wrapper element 223 | var topAdjustment = this._alignmentElementOffset.top - this._wrapperElementOffset.top; 224 | var bottomAdjustment = this._alignmentElementOffset.bottom - this._wrapperElementOffset.bottom; 225 | 226 | var bottomClearanceExists = 227 | bottomClearance >= this._menuHeight || 228 | (topClearance < this._menuHeight && bottomClearance >= topClearance); 229 | 230 | if (bottomClearanceExists) { 231 | this.$container.css({ 232 | top: 'calc(100% + ' + bottomAdjustment + 'px)', 233 | bottom: 'unset', 234 | maxHeight: bottomClearance - this.settings.windowSpacing, 235 | }); 236 | } else { 237 | this.$container.css({ 238 | bottom: 'calc(100% - ' + topAdjustment + 'px)', 239 | top: 'unset', 240 | maxHeight: topClearance - this.settings.windowSpacing, 241 | }); 242 | } 243 | 244 | // Figure out how we're aliging it 245 | var align = this.$container.data('align'); 246 | 247 | if (align !== 'left' && align !== 'center' && align !== 'right') { 248 | align = 'left'; 249 | } 250 | 251 | if (align === 'center') { 252 | this._alignCenter(); 253 | } else { 254 | // Figure out which options are actually possible 255 | var rightClearance = 256 | this._windowWidth + 257 | this._windowScrollLeft - 258 | (this._alignmentElementOffset.left + this._menuWidth), 259 | leftClearance = this._alignmentElementOffset.right - this._menuWidth; 260 | 261 | if ((align === 'right' && leftClearance >= 0) || rightClearance < 0) { 262 | this._alignRight(); 263 | } else { 264 | this._alignLeft(); 265 | } 266 | } 267 | 268 | delete this._windowWidth; 269 | delete this._windowHeight; 270 | delete this._windowScrollLeft; 271 | delete this._windowScrollTop; 272 | delete this._wrapperElementOffset; 273 | delete this._alignmentElementOffset; 274 | delete this._triggerWidth; 275 | delete this._triggerHeight; 276 | delete this._menuWidth; 277 | delete this._menuHeight; 278 | }, 279 | 280 | _alignLeft: function () { 281 | var leftAdjustment = this._alignmentElementOffset.left - this._wrapperElementOffset.left; 282 | 283 | this.$container.css({ 284 | right: 'unset', 285 | left: leftAdjustment + 'px', 286 | }); 287 | }, 288 | 289 | _alignRight: function () { 290 | var rightAdjustment = this._alignmentElementOffset.right - this._wrapperElementOffset.right; 291 | 292 | this.$container.css({ 293 | left: 'unset', 294 | right: - rightAdjustment + 'px', 295 | }); 296 | }, 297 | 298 | _alignCenter: function () { 299 | var left = Math.round(this._triggerWidth / 2 - this._menuWidth / 2); 300 | var leftAdjustment = this._alignmentElementOffset.left - this._wrapperElementOffset.left; 301 | 302 | this.$container.css('left', left - leftAdjustment); 303 | }, 304 | }, 305 | { 306 | defaults: { 307 | windowSpacing: 5, 308 | }, 309 | } 310 | ); 311 | -------------------------------------------------------------------------------- /src/Drag.js: -------------------------------------------------------------------------------- 1 | /** global: Garnish */ 2 | /** 3 | * Drag class 4 | * 5 | * Builds on the BaseDrag class by "picking up" the selceted element(s), 6 | * without worrying about what to do when an element is being dragged. 7 | */ 8 | Garnish.Drag = Garnish.BaseDrag.extend( 9 | { 10 | targetItemWidth: null, 11 | targetItemHeight: null, 12 | targetItemPositionInDraggee: null, 13 | 14 | $draggee: null, 15 | 16 | otherItems: null, 17 | totalOtherItems: null, 18 | 19 | helpers: null, 20 | helperTargets: null, 21 | helperPositions: null, 22 | helperLagIncrement: null, 23 | updateHelperPosProxy: null, 24 | updateHelperPosFrame: null, 25 | 26 | lastMouseX: null, 27 | lastMouseY: null, 28 | 29 | _returningHelpersToDraggees: false, 30 | 31 | /** 32 | * Constructor 33 | * 34 | * @param {object} items Elements that should be draggable right away. (Can be skipped.) 35 | * @param {object} settings Any settings that should override the defaults. 36 | */ 37 | init: function(items, settings) { 38 | // Param mapping 39 | if (typeof settings === 'undefined' && $.isPlainObject(items)) { 40 | // (settings) 41 | settings = items; 42 | items = null; 43 | } 44 | 45 | settings = $.extend({}, Garnish.Drag.defaults, settings); 46 | this.base(items, settings); 47 | }, 48 | 49 | /** 50 | * Returns whether dragging is allowed right now. 51 | */ 52 | allowDragging: function() { 53 | // Don't allow dragging if we're in the middle of animating the helpers back to the draggees 54 | return !this._returningHelpersToDraggees; 55 | }, 56 | 57 | /** 58 | * Start Dragging 59 | */ 60 | startDragging: function() { 61 | // Reset some things 62 | this.helpers = []; 63 | this.helperTargets = []; 64 | this.helperPositions = []; 65 | this.lastMouseX = this.lastMouseY = null; 66 | 67 | // Capture the target item's width/height 68 | this.targetItemWidth = this.$targetItem.outerWidth(); 69 | this.targetItemHeight = this.$targetItem.outerHeight(); 70 | 71 | // Save the draggee's display style (block/table-row) so we can re-apply it later 72 | this.draggeeDisplay = this.$targetItem.css('display'); 73 | 74 | // Set the $draggee 75 | this.setDraggee(this.findDraggee()); 76 | 77 | // Create an array of all the other items 78 | this.otherItems = []; 79 | 80 | for (var i = 0; i < this.$items.length; i++) { 81 | var item = this.$items[i]; 82 | 83 | if ($.inArray(item, this.$draggee) === -1) { 84 | this.otherItems.push(item); 85 | } 86 | } 87 | 88 | this.totalOtherItems = this.otherItems.length; 89 | 90 | // Keep the helpers following the cursor, with a little lag to smooth it out 91 | if (!this.updateHelperPosProxy) { 92 | this.updateHelperPosProxy = this._updateHelperPos.bind(this); 93 | } 94 | 95 | this.helperLagIncrement = this.helpers.length === 1 ? 0 : this.settings.helperLagIncrementDividend / (this.helpers.length - 1); 96 | this.updateHelperPosFrame = Garnish.requestAnimationFrame(this.updateHelperPosProxy); 97 | 98 | this.base(); 99 | }, 100 | 101 | /** 102 | * Sets the draggee. 103 | */ 104 | setDraggee: function($draggee) { 105 | // Record the target item's position in the draggee 106 | this.targetItemPositionInDraggee = $.inArray(this.$targetItem[0], $draggee.add(this.$targetItem[0])); 107 | 108 | // Keep the target item at the front of the list 109 | this.$draggee = $([this.$targetItem[0]].concat($draggee.not(this.$targetItem).toArray())); 110 | 111 | // Create the helper(s) 112 | if (this.settings.singleHelper) { 113 | this._createHelper(0); 114 | } 115 | else { 116 | for (var i = 0; i < this.$draggee.length; i++) { 117 | this._createHelper(i); 118 | } 119 | } 120 | 121 | if (this.settings.removeDraggee) { 122 | this.$draggee.hide(); 123 | } 124 | else if (this.settings.collapseDraggees) { 125 | this.$targetItem.css('visibility', 'hidden'); 126 | this.$draggee.not(this.$targetItem).hide(); 127 | } 128 | else { 129 | this.$draggee.css('visibility', 'hidden'); 130 | } 131 | }, 132 | 133 | /** 134 | * Appends additional items to the draggee. 135 | */ 136 | appendDraggee: function($newDraggee) { 137 | if (!$newDraggee.length) { 138 | return; 139 | } 140 | 141 | if (!this.settings.collapseDraggees) { 142 | var oldLength = this.$draggee.length; 143 | } 144 | 145 | this.$draggee = $(this.$draggee.toArray().concat($newDraggee.toArray())); 146 | 147 | // Create new helpers? 148 | if (!this.settings.collapseDraggees) { 149 | var newLength = this.$draggee.length; 150 | 151 | for (var i = oldLength; i < newLength; i++) { 152 | this._createHelper(i); 153 | } 154 | } 155 | 156 | if (this.settings.removeDraggee || this.settings.collapseDraggees) { 157 | $newDraggee.hide(); 158 | } 159 | else { 160 | $newDraggee.css('visibility', 'hidden'); 161 | } 162 | }, 163 | 164 | /** 165 | * Drag 166 | */ 167 | drag: function(didMouseMove) { 168 | // Update the draggee's virtual midpoint 169 | this.draggeeVirtualMidpointX = this.mouseX - this.mouseOffsetX + (this.targetItemWidth / 2); 170 | this.draggeeVirtualMidpointY = this.mouseY - this.mouseOffsetY + (this.targetItemHeight / 2); 171 | 172 | this.base(didMouseMove); 173 | }, 174 | 175 | /** 176 | * Stop Dragging 177 | */ 178 | stopDragging: function() { 179 | // Clear the helper animation 180 | Garnish.cancelAnimationFrame(this.updateHelperPosFrame); 181 | 182 | this.base(); 183 | }, 184 | 185 | /** 186 | * Identifies the item(s) that are being dragged. 187 | */ 188 | findDraggee: function() { 189 | switch (typeof this.settings.filter) { 190 | case 'function': { 191 | return this.settings.filter(); 192 | } 193 | 194 | case 'string': { 195 | return this.$items.filter(this.settings.filter); 196 | } 197 | 198 | default: { 199 | return this.$targetItem; 200 | } 201 | } 202 | }, 203 | 204 | /** 205 | * Returns the helper’s target X position 206 | */ 207 | getHelperTargetX: function() { 208 | return this.mouseX - this.mouseOffsetX; 209 | }, 210 | 211 | /** 212 | * Returns the helper’s target Y position 213 | */ 214 | getHelperTargetY: function() { 215 | return this.mouseY - this.mouseOffsetY; 216 | }, 217 | 218 | /** 219 | * Return Helpers to Draggees 220 | */ 221 | returnHelpersToDraggees: function() { 222 | this._returningHelpersToDraggees = true; 223 | 224 | for (var i = 0; i < this.helpers.length; i++) { 225 | var $draggee = this.$draggee.eq(i), 226 | $helper = this.helpers[i]; 227 | 228 | $draggee.css({ 229 | display: this.draggeeDisplay, 230 | visibility: 'hidden' 231 | }); 232 | 233 | var draggeeOffset = $draggee.offset(); 234 | var callback; 235 | 236 | if (i === 0) { 237 | callback = this._showDraggee.bind(this); 238 | } 239 | else { 240 | callback = null; 241 | } 242 | 243 | $helper.velocity({left: draggeeOffset.left, top: draggeeOffset.top}, Garnish.FX_DURATION, callback); 244 | } 245 | }, 246 | 247 | // Events 248 | // --------------------------------------------------------------------- 249 | 250 | onReturnHelpersToDraggees: function() { 251 | Garnish.requestAnimationFrame(function() { 252 | this.trigger('returnHelpersToDraggees'); 253 | this.settings.onReturnHelpersToDraggees(); 254 | }.bind(this)); 255 | }, 256 | 257 | // Private methods 258 | // --------------------------------------------------------------------- 259 | 260 | /** 261 | * Creates a helper. 262 | */ 263 | _createHelper: function(i) { 264 | var $draggee = this.$draggee.eq(i), 265 | $draggeeHelper = $draggee.clone().addClass('draghelper'); 266 | 267 | if (this.settings.copyDraggeeInputValuesToHelper) { 268 | Garnish.copyInputValues($draggee, $draggeeHelper); 269 | } 270 | 271 | // Remove any name= attributes so radio buttons don't lose their values 272 | $draggeeHelper.find('[name]').attr('name', ''); 273 | 274 | $draggeeHelper 275 | .outerWidth(Math.ceil($draggee.outerWidth())) 276 | .outerHeight(Math.ceil($draggee.outerHeight())) 277 | .css({margin: 0, 'pointer-events': 'none'}); 278 | 279 | if (this.settings.helper) { 280 | if (typeof this.settings.helper === 'function') { 281 | $draggeeHelper = this.settings.helper($draggeeHelper); 282 | } 283 | else { 284 | $draggeeHelper = $(this.settings.helper).append($draggeeHelper); 285 | } 286 | } 287 | 288 | $draggeeHelper.appendTo(Garnish.$bod); 289 | 290 | var helperPos = this._getHelperTarget(i); 291 | 292 | $draggeeHelper.css({ 293 | position: 'absolute', 294 | top: helperPos.top, 295 | left: helperPos.left, 296 | zIndex: this.settings.helperBaseZindex + this.$draggee.length - i, 297 | opacity: this.settings.helperOpacity 298 | }); 299 | 300 | this.helperPositions[i] = { 301 | top: helperPos.top, 302 | left: helperPos.left 303 | }; 304 | 305 | this.helpers.push($draggeeHelper); 306 | }, 307 | 308 | /** 309 | * Update Helper Position 310 | */ 311 | _updateHelperPos: function() { 312 | // Has the mouse moved? 313 | if (this.mouseX !== this.lastMouseX || this.mouseY !== this.lastMouseY) { 314 | // Get the new target helper positions 315 | for (this._updateHelperPos._i = 0; this._updateHelperPos._i < this.helpers.length; this._updateHelperPos._i++) { 316 | this.helperTargets[this._updateHelperPos._i] = this._getHelperTarget(this._updateHelperPos._i); 317 | } 318 | 319 | this.lastMouseX = this.mouseX; 320 | this.lastMouseY = this.mouseY; 321 | } 322 | 323 | // Gravitate helpers toward their target positions 324 | for (this._updateHelperPos._j = 0; this._updateHelperPos._j < this.helpers.length; this._updateHelperPos._j++) { 325 | this._updateHelperPos._lag = this.settings.helperLagBase + (this.helperLagIncrement * this._updateHelperPos._j); 326 | 327 | this.helperPositions[this._updateHelperPos._j] = { 328 | left: this.helperPositions[this._updateHelperPos._j].left + ((this.helperTargets[this._updateHelperPos._j].left - this.helperPositions[this._updateHelperPos._j].left) / this._updateHelperPos._lag), 329 | top: this.helperPositions[this._updateHelperPos._j].top + ((this.helperTargets[this._updateHelperPos._j].top - this.helperPositions[this._updateHelperPos._j].top) / this._updateHelperPos._lag) 330 | }; 331 | 332 | this.helpers[this._updateHelperPos._j].css(this.helperPositions[this._updateHelperPos._j]); 333 | } 334 | 335 | // Let's do this again on the next frame! 336 | this.updateHelperPosFrame = Garnish.requestAnimationFrame(this.updateHelperPosProxy); 337 | }, 338 | 339 | /** 340 | * Get the helper position for a draggee helper 341 | */ 342 | _getHelperTarget: function(i) { 343 | return { 344 | left: this.getHelperTargetX() + (this.settings.helperSpacingX * i), 345 | top: this.getHelperTargetY() + (this.settings.helperSpacingY * i) 346 | }; 347 | }, 348 | 349 | _showDraggee: function() { 350 | // Remove the helpers 351 | for (var i = 0; i < this.helpers.length; i++) { 352 | this.helpers[i].remove(); 353 | } 354 | 355 | this.helpers = null; 356 | 357 | this.$draggee.show().css('visibility', 'inherit'); 358 | 359 | this.onReturnHelpersToDraggees(); 360 | 361 | this._returningHelpersToDraggees = false; 362 | } 363 | }, 364 | { 365 | defaults: { 366 | filter: null, 367 | singleHelper: false, 368 | collapseDraggees: false, 369 | removeDraggee: false, 370 | copyDraggeeInputValuesToHelper: false, 371 | helperOpacity: 1, 372 | helper: null, 373 | helperBaseZindex: 1000, 374 | helperLagBase: 1, 375 | helperLagIncrementDividend: 1.5, 376 | helperSpacingX: 5, 377 | helperSpacingY: 5, 378 | onReturnHelpersToDraggees: $.noop 379 | } 380 | } 381 | ); 382 | -------------------------------------------------------------------------------- /src/DragDrop.js: -------------------------------------------------------------------------------- 1 | /** global: Garnish */ 2 | /** 3 | * Drag-and-drop class 4 | * 5 | * Builds on the Drag class by allowing you to set up "drop targets" 6 | * which the dragged elemements can be dropped onto. 7 | */ 8 | Garnish.DragDrop = Garnish.Drag.extend({ 9 | 10 | $dropTargets: null, 11 | $activeDropTarget: null, 12 | 13 | /** 14 | * Constructor 15 | */ 16 | init: function(settings) { 17 | settings = $.extend({}, Garnish.DragDrop.defaults, settings); 18 | this.base(settings); 19 | }, 20 | 21 | updateDropTargets: function() { 22 | if (this.settings.dropTargets) { 23 | if (typeof this.settings.dropTargets === 'function') { 24 | this.$dropTargets = $(this.settings.dropTargets()); 25 | } 26 | else { 27 | this.$dropTargets = $(this.settings.dropTargets); 28 | } 29 | 30 | // Discard if it's an empty array 31 | if (!this.$dropTargets.length) { 32 | this.$dropTargets = null; 33 | } 34 | } 35 | }, 36 | 37 | /** 38 | * On Drag Start 39 | */ 40 | onDragStart: function() { 41 | this.updateDropTargets(); 42 | this.$activeDropTarget = null; 43 | this.base(); 44 | }, 45 | 46 | /** 47 | * On Drag 48 | */ 49 | onDrag: function() { 50 | if (this.$dropTargets) { 51 | this.onDrag._activeDropTarget = null; 52 | 53 | // is the cursor over any of the drop target? 54 | for (this.onDrag._i = 0; this.onDrag._i < this.$dropTargets.length; this.onDrag._i++) { 55 | this.onDrag._elem = this.$dropTargets[this.onDrag._i]; 56 | 57 | if (Garnish.hitTest(this.mouseX, this.mouseY, this.onDrag._elem)) { 58 | this.onDrag._activeDropTarget = this.onDrag._elem; 59 | break; 60 | } 61 | } 62 | 63 | // has the drop target changed? 64 | if ( 65 | (this.$activeDropTarget && this.onDrag._activeDropTarget !== this.$activeDropTarget[0]) || 66 | (!this.$activeDropTarget && this.onDrag._activeDropTarget !== null) 67 | ) { 68 | // was there a previous one? 69 | if (this.$activeDropTarget) { 70 | this.$activeDropTarget.removeClass(this.settings.activeDropTargetClass); 71 | } 72 | 73 | // remember the new one 74 | if (this.onDrag._activeDropTarget) { 75 | this.$activeDropTarget = $(this.onDrag._activeDropTarget).addClass(this.settings.activeDropTargetClass); 76 | } 77 | else { 78 | this.$activeDropTarget = null; 79 | } 80 | 81 | this.settings.onDropTargetChange(this.$activeDropTarget); 82 | } 83 | } 84 | 85 | this.base(); 86 | }, 87 | 88 | /** 89 | * On Drag Stop 90 | */ 91 | onDragStop: function() { 92 | if (this.$dropTargets && this.$activeDropTarget) { 93 | this.$activeDropTarget.removeClass(this.settings.activeDropTargetClass); 94 | } 95 | 96 | this.base(); 97 | }, 98 | 99 | /** 100 | * Fade Out Helpers 101 | */ 102 | fadeOutHelpers: function() { 103 | for (var i = 0; i < this.helpers.length; i++) { 104 | (function($draggeeHelper) { 105 | $draggeeHelper.velocity('fadeOut', { 106 | duration: Garnish.FX_DURATION, 107 | complete: function() { 108 | $draggeeHelper.remove(); 109 | } 110 | }); 111 | })(this.helpers[i]); 112 | } 113 | } 114 | }, 115 | { 116 | defaults: { 117 | dropTargets: null, 118 | onDropTargetChange: $.noop, 119 | activeDropTargetClass: 'active' 120 | } 121 | }); 122 | -------------------------------------------------------------------------------- /src/DragMove.js: -------------------------------------------------------------------------------- 1 | /** global: Garnish */ 2 | /** 3 | * Drag-to-move clas 4 | * 5 | * Builds on the BaseDrag class by simply moving the dragged element(s) along with the mouse. 6 | */ 7 | Garnish.DragMove = Garnish.BaseDrag.extend( 8 | { 9 | onDrag: function(items, settings) { 10 | this.$targetItem.css({ 11 | left: this.mouseX - this.mouseOffsetX, 12 | top: this.mouseY - this.mouseOffsetY 13 | }); 14 | } 15 | } 16 | ); 17 | -------------------------------------------------------------------------------- /src/DragSort.js: -------------------------------------------------------------------------------- 1 | /** global: Garnish */ 2 | /** 3 | * Drag-to-sort class 4 | * 5 | * Builds on the Drag class by allowing you to sort the elements amongst themselves. 6 | */ 7 | Garnish.DragSort = Garnish.Drag.extend( 8 | { 9 | $heightedContainer: null, 10 | $insertion: null, 11 | insertionVisible: false, 12 | oldDraggeeIndexes: null, 13 | newDraggeeIndexes: null, 14 | closestItem: null, 15 | 16 | _midpointVersion: 0, 17 | _$prevItem: null, 18 | 19 | /** 20 | * Constructor 21 | * 22 | * @param {object} items Elements that should be draggable right away. (Can be skipped.) 23 | * @param {object} settings Any settings that should override the defaults. 24 | */ 25 | init: function(items, settings) { 26 | // Param mapping 27 | if (typeof settings === 'undefined' && $.isPlainObject(items)) { 28 | // (settings) 29 | settings = items; 30 | items = null; 31 | } 32 | 33 | settings = $.extend({}, Garnish.DragSort.defaults, settings); 34 | this.base(items, settings); 35 | }, 36 | 37 | /** 38 | * Creates the insertion element. 39 | */ 40 | createInsertion: function() { 41 | if (this.settings.insertion) { 42 | if (typeof this.settings.insertion === 'function') { 43 | return $(this.settings.insertion(this.$draggee)); 44 | } 45 | else { 46 | return $(this.settings.insertion); 47 | } 48 | } 49 | }, 50 | 51 | /** 52 | * Returns the helper’s target X position 53 | */ 54 | getHelperTargetX: function() { 55 | if (this.settings.magnetStrength !== 1) { 56 | this.getHelperTargetX._draggeeOffsetX = this.$draggee.offset().left; 57 | return this.getHelperTargetX._draggeeOffsetX + ((this.mouseX - this.mouseOffsetX - this.getHelperTargetX._draggeeOffsetX) / this.settings.magnetStrength); 58 | } 59 | else { 60 | return this.base(); 61 | } 62 | }, 63 | 64 | /** 65 | * Returns the helper’s target Y position 66 | */ 67 | getHelperTargetY: function() { 68 | if (this.settings.magnetStrength !== 1) { 69 | this.getHelperTargetY._draggeeOffsetY = this.$draggee.offset().top; 70 | return this.getHelperTargetY._draggeeOffsetY + ((this.mouseY - this.mouseOffsetY - this.getHelperTargetY._draggeeOffsetY) / this.settings.magnetStrength); 71 | } 72 | else { 73 | return this.base(); 74 | } 75 | }, 76 | 77 | /** 78 | * Returns whether the draggee can be inserted before a given item. 79 | */ 80 | canInsertBefore: function($item) { 81 | return true; 82 | }, 83 | 84 | /** 85 | * Returns whether the draggee can be inserted after a given item. 86 | */ 87 | canInsertAfter: function($item) { 88 | return true; 89 | }, 90 | 91 | // Events 92 | // --------------------------------------------------------------------- 93 | 94 | /** 95 | * On Drag Start 96 | */ 97 | onDragStart: function() { 98 | this.oldDraggeeIndexes = this._getDraggeeIndexes(); 99 | 100 | // Are we supposed to be moving the target item to the front, and is it not already there? 101 | if ( 102 | this.settings.moveTargetItemToFront && 103 | this.$draggee.length > 1 && 104 | this._getItemIndex(this.$draggee[0]) > this._getItemIndex(this.$draggee[1]) 105 | ) { 106 | // Reposition the target item before the other draggee items in the DOM 107 | this.$draggee.first().insertBefore(this.$draggee[1]); 108 | } 109 | 110 | // Create the insertion 111 | this.$insertion = this.createInsertion(); 112 | this._placeInsertionWithDraggee(); 113 | 114 | this.closestItem = null; 115 | this._clearMidpoints(); 116 | 117 | // Get the closest container that has a height 118 | if (this.settings.container) { 119 | this.$heightedContainer = $(this.settings.container); 120 | 121 | while (!this.$heightedContainer.height()) { 122 | this.$heightedContainer = this.$heightedContainer.parent(); 123 | } 124 | } 125 | 126 | this.base(); 127 | }, 128 | 129 | /** 130 | * On Drag 131 | */ 132 | onDrag: function() { 133 | // If there's a container set, make sure that we're hovering over it 134 | if (this.$heightedContainer && !Garnish.hitTest(this.mouseX, this.mouseY, this.$heightedContainer)) { 135 | if (this.closestItem) { 136 | this.closestItem = null; 137 | this._removeInsertion(); 138 | } 139 | } 140 | else { 141 | // Is there a new closest item? 142 | if ( 143 | this.closestItem !== (this.closestItem = this._getClosestItem()) && 144 | this.closestItem !== null 145 | ) { 146 | this._updateInsertion(); 147 | } 148 | } 149 | 150 | this.base(); 151 | }, 152 | 153 | /** 154 | * On Drag Stop 155 | */ 156 | onDragStop: function() { 157 | this._removeInsertion(); 158 | 159 | // Should we keep the target item where it was? 160 | if (!this.settings.moveTargetItemToFront && this.targetItemPositionInDraggee !== 0) { 161 | this.$targetItem.insertAfter(this.$draggee.eq(this.targetItemPositionInDraggee)); 162 | } 163 | 164 | // Return the helpers to the draggees 165 | this.returnHelpersToDraggees(); 166 | 167 | this.base(); 168 | 169 | // Has the item actually moved? 170 | this.$items = $().add(this.$items); 171 | this.newDraggeeIndexes = this._getDraggeeIndexes(); 172 | 173 | if (this.newDraggeeIndexes.join(',') !== this.oldDraggeeIndexes.join(',')) { 174 | this.onSortChange(); 175 | } 176 | }, 177 | 178 | /** 179 | * On Insertion Point Change event 180 | */ 181 | onInsertionPointChange: function() { 182 | Garnish.requestAnimationFrame(function() { 183 | this.trigger('insertionPointChange'); 184 | this.settings.onInsertionPointChange(); 185 | }.bind(this)); 186 | }, 187 | 188 | /** 189 | * On Sort Change event 190 | */ 191 | onSortChange: function() { 192 | Garnish.requestAnimationFrame(function() { 193 | this.trigger('sortChange'); 194 | this.settings.onSortChange(); 195 | }.bind(this)); 196 | }, 197 | 198 | // Private methods 199 | // --------------------------------------------------------------------- 200 | 201 | _getItemIndex: function(item) { 202 | return $.inArray(item, this.$items); 203 | }, 204 | 205 | _getDraggeeIndexes: function() { 206 | var indexes = []; 207 | 208 | for (var i = 0; i < this.$draggee.length; i++) { 209 | indexes.push(this._getItemIndex(this.$draggee[i])) 210 | } 211 | 212 | return indexes; 213 | }, 214 | 215 | /** 216 | * Returns the closest item to the cursor. 217 | */ 218 | _getClosestItem: function() { 219 | this._getClosestItem._closestItem = null; 220 | 221 | // Start by checking the draggee/insertion, if either are visible 222 | if (!this.settings.removeDraggee) { 223 | this._testForClosestItem(this.$draggee[0]); 224 | } 225 | else if (this.insertionVisible) { 226 | this._testForClosestItem(this.$insertion[0]); 227 | } 228 | 229 | // Check items before the draggee 230 | if (this._getClosestItem._closestItem) { 231 | this._getClosestItem._midpoint = this._getItemMidpoint(this._getClosestItem._closestItem) 232 | } 233 | if (this.settings.axis !== Garnish.Y_AXIS) { 234 | this._getClosestItem._startXDist = this._getClosestItem._lastXDist = this._getClosestItem._closestItem ? Math.abs(this._getClosestItem._midpoint.x - this.draggeeVirtualMidpointX) : null; 235 | } 236 | if (this.settings.axis !== Garnish.X_AXIS) { 237 | this._getClosestItem._startYDist = this._getClosestItem._lastYDist = this._getClosestItem._closestItem ? Math.abs(this._getClosestItem._midpoint.y - this.draggeeVirtualMidpointY) : null; 238 | } 239 | 240 | this._getClosestItem._$otherItem = this.$draggee.first().prev(); 241 | 242 | while (this._getClosestItem._$otherItem.length) { 243 | // See if we're just getting further away 244 | this._getClosestItem._midpoint = this._getItemMidpoint(this._getClosestItem._$otherItem[0]); 245 | if (this.settings.axis !== Garnish.Y_AXIS) { 246 | this._getClosestItem._xDist = Math.abs(this._getClosestItem._midpoint.x - this.draggeeVirtualMidpointX); 247 | } 248 | if (this.settings.axis !== Garnish.X_AXIS) { 249 | this._getClosestItem._yDist = Math.abs(this._getClosestItem._midpoint.y - this.draggeeVirtualMidpointY); 250 | } 251 | 252 | if ( 253 | (this.settings.axis === Garnish.Y_AXIS || (this._getClosestItem._lastXDist !== null && this._getClosestItem._xDist > this._getClosestItem._lastXDist)) && 254 | (this.settings.axis === Garnish.X_AXIS || (this._getClosestItem._lastYDist !== null && this._getClosestItem._yDist > this._getClosestItem._lastYDist)) 255 | ) { 256 | break; 257 | } 258 | 259 | if (this.settings.axis !== Garnish.Y_AXIS) { 260 | this._getClosestItem._lastXDist = this._getClosestItem._xDist; 261 | } 262 | if (this.settings.axis !== Garnish.X_AXIS) { 263 | this._getClosestItem._lastYDist = this._getClosestItem._yDist; 264 | } 265 | 266 | // Give the extending class a chance to allow/disallow this item 267 | if (this.canInsertBefore(this._getClosestItem._$otherItem)) { 268 | this._testForClosestItem(this._getClosestItem._$otherItem[0]); 269 | } 270 | 271 | // Prep the next item 272 | this._getClosestItem._$otherItem = this._getClosestItem._$otherItem.prev(); 273 | } 274 | 275 | // Check items after the draggee 276 | if (this.settings.axis !== Garnish.Y_AXIS) { 277 | this._getClosestItem._lastXDist = this._getClosestItem._startXDist; 278 | } 279 | if (this.settings.axis !== Garnish.X_AXIS) { 280 | this._getClosestItem._lastYDist = this._getClosestItem._startYDist; 281 | } 282 | 283 | this._getClosestItem._$otherItem = this.$draggee.last().next(); 284 | 285 | while (this._getClosestItem._$otherItem.length) { 286 | // See if we're just getting further away 287 | this._getClosestItem._midpoint = this._getItemMidpoint(this._getClosestItem._$otherItem[0]); 288 | if (this.settings.axis !== Garnish.Y_AXIS) { 289 | this._getClosestItem._xDist = Math.abs(this._getClosestItem._midpoint.x - this.draggeeVirtualMidpointX); 290 | } 291 | if (this.settings.axis !== Garnish.X_AXIS) { 292 | this._getClosestItem._yDist = Math.abs(this._getClosestItem._midpoint.y - this.draggeeVirtualMidpointY); 293 | } 294 | 295 | if ( 296 | (this.settings.axis === Garnish.Y_AXIS || (this._getClosestItem._lastXDist !== null && this._getClosestItem._xDist > this._getClosestItem._lastXDist)) && 297 | (this.settings.axis === Garnish.X_AXIS || (this._getClosestItem._lastYDist !== null && this._getClosestItem._yDist > this._getClosestItem._lastYDist)) 298 | ) { 299 | break; 300 | } 301 | 302 | if (this.settings.axis !== Garnish.Y_AXIS) { 303 | this._getClosestItem._lastXDist = this._getClosestItem._xDist; 304 | } 305 | if (this.settings.axis !== Garnish.X_AXIS) { 306 | this._getClosestItem._lastYDist = this._getClosestItem._yDist; 307 | } 308 | 309 | // Give the extending class a chance to allow/disallow this item 310 | if (this.canInsertAfter(this._getClosestItem._$otherItem)) { 311 | this._testForClosestItem(this._getClosestItem._$otherItem[0]); 312 | } 313 | 314 | // Prep the next item 315 | this._getClosestItem._$otherItem = this._getClosestItem._$otherItem.next(); 316 | } 317 | 318 | // Return the result 319 | 320 | // Ignore if it's the draggee or insertion 321 | if ( 322 | this._getClosestItem._closestItem !== this.$draggee[0] && 323 | (!this.insertionVisible || this._getClosestItem._closestItem !== this.$insertion[0]) 324 | ) { 325 | return this._getClosestItem._closestItem; 326 | } 327 | else { 328 | return null; 329 | } 330 | }, 331 | 332 | _clearMidpoints: function() { 333 | this._midpointVersion++; 334 | this._$prevItem = null; 335 | }, 336 | 337 | _getItemMidpoint: function(item) { 338 | if ($.data(item, 'midpointVersion') !== this._midpointVersion) { 339 | // If this isn't the draggee, temporarily move the draggee to this item 340 | this._getItemMidpoint._repositionDraggee = ( 341 | !this.settings.axis && 342 | (!this.settings.removeDraggee || this.insertionVisible) && 343 | item !== this.$draggee[0] && 344 | (!this.$insertion || item !== this.$insertion.get(0)) 345 | ); 346 | 347 | if (this._getItemMidpoint._repositionDraggee) { 348 | // Is this the first time we've had to temporarily reposition the draggee since the last midpoint clearing? 349 | if (!this._$prevItem) { 350 | this._$prevItem = (this.insertionVisible ? this.$insertion : this.$draggee).first().prev(); 351 | } 352 | 353 | this._moveDraggeeToItem(item); 354 | 355 | // Now figure out which element we're actually getting the midpoint of 356 | if (!this.settings.removeDraggee) { 357 | this._getItemMidpoint._$item = this.$draggee; 358 | } 359 | else { 360 | this._getItemMidpoint._$item = this.$insertion; 361 | } 362 | } 363 | else { 364 | // We're actually getting the midpoint of this item 365 | this._getItemMidpoint._$item = $(item); 366 | } 367 | 368 | this._getItemMidpoint._offset = this._getItemMidpoint._$item.offset(); 369 | 370 | $.data(item, 'midpoint', { 371 | x: this._getItemMidpoint._offset.left + this._getItemMidpoint._$item.outerWidth() / 2, 372 | y: this._getItemMidpoint._offset.top + this._getItemMidpoint._$item.outerHeight() / 2 373 | }); 374 | 375 | $.data(item, 'midpointVersion', this._midpointVersion); 376 | 377 | delete this._getItemMidpoint._$item; 378 | delete this._getItemMidpoint._offset; 379 | 380 | if (this._getItemMidpoint._repositionDraggee) { 381 | // Move the draggee back 382 | if (this._$prevItem.length) { 383 | this.$draggee.insertAfter(this._$prevItem); 384 | } 385 | else { 386 | this.$draggee.prependTo(this.$draggee.parent()); 387 | } 388 | 389 | this._placeInsertionWithDraggee(); 390 | } 391 | } 392 | 393 | return $.data(item, 'midpoint'); 394 | }, 395 | 396 | _testForClosestItem: function(item) { 397 | this._testForClosestItem._midpoint = this._getItemMidpoint(item); 398 | this._testForClosestItem._mouseDistX = Math.abs(this._testForClosestItem._midpoint.x - this.draggeeVirtualMidpointX); 399 | this._testForClosestItem._mouseDistY = Math.abs(this._testForClosestItem._midpoint.y - this.draggeeVirtualMidpointY); 400 | 401 | // Don't even consider items that are further away on the Y axis 402 | if ( 403 | this._getClosestItem._closestItem === null || 404 | this._testForClosestItem._mouseDistY < this._getClosestItem._closestItemMouseDistY || 405 | ( 406 | this._testForClosestItem._mouseDistY === this._getClosestItem._closestItemMouseDistY && 407 | this._testForClosestItem._mouseDistX <= this._getClosestItem._closestItemMouseDistX 408 | ) 409 | ) { 410 | this._getClosestItem._closestItem = item; 411 | this._getClosestItem._closestItemMouseDistX = this._testForClosestItem._mouseDistX; 412 | this._getClosestItem._closestItemMouseDistY = this._testForClosestItem._mouseDistY; 413 | } 414 | }, 415 | 416 | /** 417 | * Updates the position of the insertion point. 418 | */ 419 | _updateInsertion: function() { 420 | if (this.closestItem) { 421 | this._moveDraggeeToItem(this.closestItem); 422 | } 423 | 424 | // Now that things have shifted around, invalidate the midpoints 425 | this._clearMidpoints(); 426 | 427 | this.onInsertionPointChange(); 428 | }, 429 | 430 | _moveDraggeeToItem: function(item) { 431 | // Going down? 432 | if (this.$draggee.index() < $(item).index()) { 433 | this.$draggee.insertAfter(item); 434 | } 435 | else { 436 | this.$draggee.insertBefore(item); 437 | } 438 | 439 | this._placeInsertionWithDraggee(); 440 | }, 441 | 442 | _placeInsertionWithDraggee: function() { 443 | if (this.$insertion) { 444 | this.$insertion.insertBefore(this.$draggee.first()); 445 | this.insertionVisible = true; 446 | } 447 | }, 448 | 449 | /** 450 | * Removes the insertion, if it's visible. 451 | */ 452 | _removeInsertion: function() { 453 | if (this.insertionVisible) { 454 | this.$insertion.remove(); 455 | this.insertionVisible = false; 456 | } 457 | } 458 | }, 459 | { 460 | defaults: { 461 | container: null, 462 | insertion: null, 463 | moveTargetItemToFront: false, 464 | magnetStrength: 1, 465 | onInsertionPointChange: $.noop, 466 | onSortChange: $.noop 467 | } 468 | } 469 | ); 470 | -------------------------------------------------------------------------------- /src/EscManager.js: -------------------------------------------------------------------------------- 1 | /** global: Garnish */ 2 | /** 3 | * ESC key manager class 4 | * @deprecated Use Garnish.ShortcutManager instead 5 | */ 6 | Garnish.EscManager = Garnish.Base.extend( 7 | { 8 | handlers: null, 9 | 10 | init: function() { 11 | this.handlers = []; 12 | 13 | this.addListener(Garnish.$bod, 'keyup', function(ev) { 14 | if (ev.keyCode === Garnish.ESC_KEY) { 15 | this.escapeLatest(ev); 16 | } 17 | }); 18 | }, 19 | 20 | register: function(obj, func) { 21 | this.handlers.push({ 22 | obj: obj, 23 | func: func 24 | }); 25 | }, 26 | 27 | unregister: function(obj) { 28 | for (var i = this.handlers.length - 1; i >= 0; i--) { 29 | if (this.handlers[i].obj === obj) { 30 | this.handlers.splice(i, 1); 31 | } 32 | } 33 | }, 34 | 35 | escapeLatest: function(ev) { 36 | if (this.handlers.length) { 37 | var handler = this.handlers.pop(); 38 | 39 | var func; 40 | 41 | if (typeof handler.func === 'function') { 42 | func = handler.func; 43 | } 44 | else { 45 | func = handler.obj[handler.func]; 46 | } 47 | 48 | func.call(handler.obj, ev); 49 | 50 | if (typeof handler.obj.trigger === 'function') { 51 | handler.obj.trigger('escape'); 52 | } 53 | } 54 | } 55 | } 56 | ); 57 | 58 | Garnish.escManager = new Garnish.EscManager(); 59 | -------------------------------------------------------------------------------- /src/Garnish.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @namespace Garnish 3 | */ 4 | 5 | // Bail if Garnish is already defined 6 | if (typeof Garnish !== 'undefined') { 7 | throw 'Garnish is already defined!'; 8 | } 9 | 10 | Garnish = { 11 | 12 | // jQuery objects for common elements 13 | $win: $(window), 14 | $doc: $(document), 15 | $bod: $(document.body) 16 | 17 | }; 18 | 19 | Garnish.rtl = Garnish.$bod.hasClass('rtl'); 20 | Garnish.ltr = !Garnish.rtl; 21 | 22 | Garnish = $.extend(Garnish, { 23 | 24 | $scrollContainer: Garnish.$win, 25 | 26 | // Key code constants 27 | DELETE_KEY: 8, 28 | SHIFT_KEY: 16, 29 | CTRL_KEY: 17, 30 | ALT_KEY: 18, 31 | RETURN_KEY: 13, 32 | ESC_KEY: 27, 33 | SPACE_KEY: 32, 34 | LEFT_KEY: 37, 35 | UP_KEY: 38, 36 | RIGHT_KEY: 39, 37 | DOWN_KEY: 40, 38 | A_KEY: 65, 39 | S_KEY: 83, 40 | CMD_KEY: 91, 41 | 42 | // Mouse button constants 43 | PRIMARY_CLICK: 1, 44 | SECONDARY_CLICK: 3, 45 | 46 | // Axis constants 47 | X_AXIS: 'x', 48 | Y_AXIS: 'y', 49 | 50 | FX_DURATION: 100, 51 | 52 | // Node types 53 | TEXT_NODE: 3, 54 | 55 | /** 56 | * Logs a message to the browser's console, if the browser has one. 57 | * 58 | * @param {string} msg 59 | */ 60 | log: function(msg) { 61 | if (typeof console !== 'undefined' && typeof console.log === 'function') { 62 | console.log(msg); 63 | } 64 | }, 65 | 66 | _isMobileBrowser: null, 67 | _isMobileOrTabletBrowser: null, 68 | 69 | /** 70 | * Returns whether this is a mobile browser. 71 | * Detection script courtesy of http://detectmobilebrowsers.com 72 | * 73 | * Last updated: 2014-11-24 74 | * 75 | * @param {boolean} detectTablets 76 | * @return {boolean} 77 | */ 78 | isMobileBrowser: function(detectTablets) { 79 | var key = detectTablets ? '_isMobileOrTabletBrowser' : '_isMobileBrowser'; 80 | 81 | if (Garnish[key] === null) { 82 | var a = navigator.userAgent || navigator.vendor || window.opera; 83 | Garnish[key] = ((new RegExp('(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino' + (detectTablets ? '|android|ipad|playbook|silk' : ''), 'i')).test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))); 84 | } 85 | 86 | return Garnish[key]; 87 | }, 88 | 89 | /** 90 | * Returns whether a variable is an array. 91 | * 92 | * @param {object} val 93 | * @return {boolean} 94 | */ 95 | isArray: function(val) { 96 | return (val instanceof Array); 97 | }, 98 | 99 | /** 100 | * Returns whether a variable is a jQuery collection. 101 | * 102 | * @param {object} val 103 | * @return {boolean} 104 | */ 105 | isJquery: function(val) { 106 | return (val instanceof jQuery); 107 | }, 108 | 109 | /** 110 | * Returns whether a variable is a string. 111 | * 112 | * @param {object} val 113 | * @return {boolean} 114 | */ 115 | isString: function(val) { 116 | return (typeof val === 'string'); 117 | }, 118 | 119 | /** 120 | * Returns whether an element has an attribute. 121 | * 122 | * @see http://stackoverflow.com/questions/1318076/jquery-hasattr-checking-to-see-if-there-is-an-attribute-on-an-element/1318091#1318091 123 | */ 124 | hasAttr: function(elem, attr) { 125 | var val = $(elem).attr(attr); 126 | return (typeof val !== 'undefined' && val !== false); 127 | }, 128 | 129 | /** 130 | * Returns whether something is a text node. 131 | * 132 | * @param {object} elem 133 | * @return {boolean} 134 | */ 135 | isTextNode: function(elem) { 136 | return (elem.nodeType === Garnish.TEXT_NODE); 137 | }, 138 | 139 | /** 140 | * Returns the offset of an element within the scroll container, whether that's the window or something else 141 | */ 142 | getOffset: function(elem) { 143 | this.getOffset._offset = $(elem).offset(); 144 | 145 | if (Garnish.$scrollContainer[0] !== Garnish.$win[0]) { 146 | this.getOffset._offset.top += Garnish.$scrollContainer.scrollTop(); 147 | this.getOffset._offset.left += Garnish.$scrollContainer.scrollLeft(); 148 | } 149 | 150 | return this.getOffset._offset; 151 | }, 152 | 153 | /** 154 | * Returns the distance between two coordinates. 155 | * 156 | * @param {number} x1 The first coordinate's X position. 157 | * @param {number} y1 The first coordinate's Y position. 158 | * @param {number} x2 The second coordinate's X position. 159 | * @param {number} y2 The second coordinate's Y position. 160 | * @return {number} 161 | */ 162 | getDist: function(x1, y1, x2, y2) { 163 | return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)); 164 | }, 165 | 166 | /** 167 | * Returns whether an element is touching an x/y coordinate. 168 | * 169 | * @param {number} x The coordinate's X position. 170 | * @param {number} y The coordinate's Y position. 171 | * @param {object} elem Either an actual element or a jQuery collection. 172 | * @return {boolean} 173 | */ 174 | hitTest: function(x, y, elem) { 175 | Garnish.hitTest._$elem = $(elem); 176 | Garnish.hitTest._offset = Garnish.hitTest._$elem.offset(); 177 | Garnish.hitTest._x1 = Garnish.hitTest._offset.left; 178 | Garnish.hitTest._y1 = Garnish.hitTest._offset.top; 179 | Garnish.hitTest._x2 = Garnish.hitTest._x1 + Garnish.hitTest._$elem.outerWidth(); 180 | Garnish.hitTest._y2 = Garnish.hitTest._y1 + Garnish.hitTest._$elem.outerHeight(); 181 | 182 | return (x >= Garnish.hitTest._x1 && x < Garnish.hitTest._x2 && y >= Garnish.hitTest._y1 && y < Garnish.hitTest._y2); 183 | }, 184 | 185 | /** 186 | * Returns whether the cursor is touching an element. 187 | * 188 | * @param {object} ev The mouse event object containing pageX and pageY properties. 189 | * @param {object} elem Either an actual element or a jQuery collection. 190 | * @return {boolean} 191 | */ 192 | isCursorOver: function(ev, elem) { 193 | return Garnish.hitTest(ev.pageX, ev.pageY, elem); 194 | }, 195 | 196 | /** 197 | * Copies text styles from one element to another. 198 | * 199 | * @param {object} source The source element. Can be either an actual element or a jQuery collection. 200 | * @param {object} target The target element. Can be either an actual element or a jQuery collection. 201 | */ 202 | copyTextStyles: function(source, target) { 203 | var $source = $(source), 204 | $target = $(target); 205 | 206 | $target.css({ 207 | fontFamily: $source.css('fontFamily'), 208 | fontSize: $source.css('fontSize'), 209 | fontWeight: $source.css('fontWeight'), 210 | letterSpacing: $source.css('letterSpacing'), 211 | lineHeight: $source.css('lineHeight'), 212 | textAlign: $source.css('textAlign'), 213 | textIndent: $source.css('textIndent'), 214 | whiteSpace: $source.css('whiteSpace'), 215 | wordSpacing: $source.css('wordSpacing'), 216 | wordWrap: $source.css('wordWrap') 217 | }); 218 | }, 219 | 220 | /** 221 | * Returns the body's real scrollTop, discarding any window banding in Safari. 222 | * 223 | * @return {number} 224 | */ 225 | getBodyScrollTop: function() { 226 | Garnish.getBodyScrollTop._scrollTop = document.body.scrollTop; 227 | 228 | if (Garnish.getBodyScrollTop._scrollTop < 0) { 229 | Garnish.getBodyScrollTop._scrollTop = 0; 230 | } 231 | else { 232 | Garnish.getBodyScrollTop._maxScrollTop = Garnish.$bod.outerHeight() - Garnish.$win.height(); 233 | 234 | if (Garnish.getBodyScrollTop._scrollTop > Garnish.getBodyScrollTop._maxScrollTop) { 235 | Garnish.getBodyScrollTop._scrollTop = Garnish.getBodyScrollTop._maxScrollTop; 236 | } 237 | } 238 | 239 | return Garnish.getBodyScrollTop._scrollTop; 240 | }, 241 | 242 | requestAnimationFrame: (function() { 243 | var raf = ( 244 | window.requestAnimationFrame || 245 | window.mozRequestAnimationFrame || 246 | window.webkitRequestAnimationFrame || 247 | function(fn) { 248 | return window.setTimeout(fn, 20); 249 | } 250 | ); 251 | 252 | return function(fn) { 253 | return raf(fn); 254 | }; 255 | })(), 256 | 257 | cancelAnimationFrame: (function() { 258 | var cancel = ( 259 | window.cancelAnimationFrame || 260 | window.mozCancelAnimationFrame || 261 | window.webkitCancelAnimationFrame || 262 | window.clearTimeout 263 | ); 264 | 265 | return function(id) { 266 | return cancel(id); 267 | }; 268 | })(), 269 | 270 | /** 271 | * Scrolls a container element to an element within it. 272 | * 273 | * @param {object} container Either an actual element or a jQuery collection. 274 | * @param {object} elem Either an actual element or a jQuery collection. 275 | */ 276 | scrollContainerToElement: function(container, elem) { 277 | var $elem; 278 | 279 | if (typeof elem === 'undefined') { 280 | $elem = $(container); 281 | $container = $elem.scrollParent(); 282 | } 283 | else { 284 | var $container = $(container); 285 | $elem = $(elem); 286 | } 287 | 288 | if ($container.prop('nodeName') === 'HTML' || $container[0] === Garnish.$doc[0]) { 289 | $container = Garnish.$win; 290 | } 291 | 292 | var scrollTop = $container.scrollTop(), 293 | elemOffset = $elem.offset().top; 294 | 295 | var elemScrollOffset; 296 | 297 | if ($container[0] === window) { 298 | elemScrollOffset = elemOffset - scrollTop; 299 | } 300 | else { 301 | elemScrollOffset = elemOffset - $container.offset().top; 302 | } 303 | 304 | var targetScrollTop = false; 305 | 306 | // Is the element above the fold? 307 | if (elemScrollOffset < 0) { 308 | targetScrollTop = scrollTop + elemScrollOffset - 10; 309 | } 310 | else { 311 | var elemHeight = $elem.outerHeight(), 312 | containerHeight = ($container[0] === window ? window.innerHeight : $container[0].clientHeight); 313 | 314 | // Is it below the fold? 315 | if (elemScrollOffset + elemHeight > containerHeight) { 316 | targetScrollTop = scrollTop + (elemScrollOffset - (containerHeight - elemHeight)) + 10; 317 | } 318 | } 319 | 320 | if (targetScrollTop !== false) { 321 | // Velocity only allows you to scroll to an arbitrary position if you're scrolling the main window 322 | if ($container[0] === window) { 323 | $('html').velocity('scroll', { 324 | offset: targetScrollTop + 'px', 325 | mobileHA: false 326 | }); 327 | } 328 | else { 329 | $container.scrollTop(targetScrollTop); 330 | } 331 | } 332 | }, 333 | 334 | SHAKE_STEPS: 10, 335 | SHAKE_STEP_DURATION: 25, 336 | 337 | /** 338 | * Shakes an element. 339 | * 340 | * @param {object} elem Either an actual element or a jQuery collection. 341 | * @param {string} prop The property that should be adjusted (default is 'margin-left'). 342 | */ 343 | shake: function(elem, prop) { 344 | var $elem = $(elem); 345 | 346 | if (!prop) { 347 | prop = 'margin-left'; 348 | } 349 | 350 | var startingPoint = parseInt($elem.css(prop)); 351 | if (isNaN(startingPoint)) { 352 | startingPoint = 0; 353 | } 354 | 355 | for (var i = 0; i <= Garnish.SHAKE_STEPS; i++) { 356 | (function(i) { 357 | setTimeout(function() { 358 | Garnish.shake._properties = {}; 359 | Garnish.shake._properties[prop] = startingPoint + (i % 2 ? -1 : 1) * (10 - i); 360 | $elem.velocity(Garnish.shake._properties, Garnish.SHAKE_STEP_DURATION); 361 | }, (Garnish.SHAKE_STEP_DURATION * i)); 362 | })(i); 363 | } 364 | }, 365 | 366 | /** 367 | * Returns the first element in an array or jQuery collection. 368 | * 369 | * @param {object} elem 370 | * @return mixed 371 | */ 372 | getElement: function(elem) { 373 | return $.makeArray(elem)[0]; 374 | }, 375 | 376 | /** 377 | * Returns the beginning of an input's name= attribute value with any [bracktes] stripped out. 378 | * 379 | * @param {object} elem 380 | * @return string|null 381 | */ 382 | getInputBasename: function(elem) { 383 | var name = $(elem).attr('name'); 384 | 385 | if (name) { 386 | return name.replace(/\[.*/, ''); 387 | } 388 | else { 389 | return null; 390 | } 391 | }, 392 | 393 | /** 394 | * Returns an input's value as it would be POSTed. 395 | * So unchecked checkboxes and radio buttons return null, 396 | * and multi-selects whose name don't end in "[]" only return the last selection 397 | * 398 | * @param {object} $input 399 | * @return {(string|string[])} 400 | */ 401 | getInputPostVal: function($input) { 402 | var type = $input.attr('type'), 403 | val = $input.val(); 404 | 405 | // Is this an unchecked checkbox or radio button? 406 | if ((type === 'checkbox' || type === 'radio')) { 407 | if ($input.prop('checked')) { 408 | return val; 409 | } 410 | else { 411 | return null; 412 | } 413 | } 414 | 415 | // Flatten any array values whose input name doesn't end in "[]" 416 | // - e.g. a multi-select 417 | else if (Garnish.isArray(val) && $input.attr('name').substr(-2) !== '[]') { 418 | if (val.length) { 419 | return val[val.length - 1]; 420 | } 421 | else { 422 | return null; 423 | } 424 | } 425 | 426 | // Just return the value 427 | else { 428 | return val; 429 | } 430 | }, 431 | 432 | /** 433 | * Returns the inputs within a container 434 | * 435 | * @param {object} container The container element. Can be either an actual element or a jQuery collection. 436 | * @return {object} 437 | */ 438 | findInputs: function(container) { 439 | return $(container).find('input,text,textarea,select,button'); 440 | }, 441 | 442 | /** 443 | * Returns the post data within a container. 444 | * 445 | * @param {object} container 446 | * @return {array} 447 | */ 448 | getPostData: function(container) { 449 | var postData = {}, 450 | arrayInputCounters = {}, 451 | $inputs = Garnish.findInputs(container); 452 | 453 | var inputName; 454 | 455 | for (var i = 0; i < $inputs.length; i++) { 456 | var $input = $inputs.eq(i); 457 | 458 | if ($input.prop('disabled')) { 459 | continue; 460 | } 461 | 462 | inputName = $input.attr('name'); 463 | if (!inputName) { 464 | continue; 465 | } 466 | 467 | var inputVal = Garnish.getInputPostVal($input); 468 | if (inputVal === null) { 469 | continue; 470 | } 471 | 472 | var isArrayInput = (inputName.substr(-2) === '[]'); 473 | 474 | if (isArrayInput) { 475 | // Get the cropped input name 476 | var croppedName = inputName.substring(0, inputName.length - 2); 477 | 478 | // Prep the input counter 479 | if (typeof arrayInputCounters[croppedName] === 'undefined') { 480 | arrayInputCounters[croppedName] = 0; 481 | } 482 | } 483 | 484 | if (!Garnish.isArray(inputVal)) { 485 | inputVal = [inputVal]; 486 | } 487 | 488 | for (var j = 0; j < inputVal.length; j++) { 489 | if (isArrayInput) { 490 | inputName = croppedName + '[' + arrayInputCounters[croppedName] + ']'; 491 | arrayInputCounters[croppedName]++; 492 | } 493 | 494 | postData[inputName] = inputVal[j]; 495 | } 496 | } 497 | 498 | return postData; 499 | }, 500 | 501 | copyInputValues: function(source, target) { 502 | var $sourceInputs = Garnish.findInputs(source), 503 | $targetInputs = Garnish.findInputs(target); 504 | 505 | for (var i = 0; i < $sourceInputs.length; i++) { 506 | if (typeof $targetInputs[i] === 'undefined') { 507 | break; 508 | } 509 | 510 | $targetInputs.eq(i).val( 511 | $sourceInputs.eq(i).val() 512 | ); 513 | } 514 | }, 515 | 516 | /** 517 | * Returns whether the "Ctrl" key is pressed (or ⌘ if this is a Mac) for a given keyboard event 518 | * 519 | * @param ev The keyboard event 520 | * 521 | * @return {boolean} Whether the "Ctrl" key is pressed 522 | */ 523 | isCtrlKeyPressed: function(ev) { 524 | if (window.navigator.platform.match(/Mac/)) { 525 | // metaKey maps to ⌘ on Macs 526 | return ev.metaKey; 527 | } 528 | return ev.ctrlKey; 529 | }, 530 | 531 | _eventHandlers: [], 532 | 533 | _normalizeEvents: function(events) { 534 | if (typeof events === 'string') { 535 | events = events.split(' '); 536 | } 537 | 538 | for (var i = 0; i < events.length; i++) { 539 | if (typeof events[i] === 'string') { 540 | events[i] = events[i].split('.'); 541 | } 542 | } 543 | 544 | return events; 545 | }, 546 | 547 | on: function(target, events, data, handler) { 548 | if (typeof data === 'function') { 549 | handler = data; 550 | data = {}; 551 | } 552 | 553 | events = this._normalizeEvents(events); 554 | 555 | for (var i = 0; i < events.length; i++) { 556 | var ev = events[i]; 557 | this._eventHandlers.push({ 558 | target: target, 559 | type: ev[0], 560 | namespace: ev[1], 561 | data: data, 562 | handler: handler 563 | }); 564 | } 565 | }, 566 | 567 | off: function(target, events, handler) { 568 | events = this._normalizeEvents(events); 569 | 570 | for (var i = 0; i < events.length; i++) { 571 | var ev = events[i]; 572 | 573 | for (var j = this._eventHandlers.length - 1; j >= 0; j--) { 574 | var eventHandler = this._eventHandlers[j]; 575 | 576 | if ( 577 | eventHandler.target === target && 578 | eventHandler.type === ev[0] && 579 | (!ev[1] || eventHandler.namespace === ev[1]) && 580 | eventHandler.handler === handler 581 | ) { 582 | this._eventHandlers.splice(j, 1); 583 | } 584 | } 585 | } 586 | } 587 | }); 588 | 589 | 590 | /** 591 | * Garnish base class 592 | */ 593 | Garnish.Base = Base.extend({ 594 | 595 | settings: null, 596 | 597 | _eventHandlers: null, 598 | _namespace: null, 599 | _$listeners: null, 600 | _disabled: false, 601 | 602 | constructor: function() { 603 | this._eventHandlers = []; 604 | this._namespace = '.Garnish' + Math.floor(Math.random() * 1000000000); 605 | this._listeners = []; 606 | this.init.apply(this, arguments); 607 | }, 608 | 609 | init: $.noop, 610 | 611 | setSettings: function(settings, defaults) { 612 | var baseSettings = (typeof this.settings === 'undefined' ? {} : this.settings); 613 | this.settings = $.extend({}, baseSettings, defaults, settings); 614 | }, 615 | 616 | on: function(events, data, handler) { 617 | if (typeof data === 'function') { 618 | handler = data; 619 | data = {}; 620 | } 621 | 622 | events = Garnish._normalizeEvents(events); 623 | 624 | for (var i = 0; i < events.length; i++) { 625 | var ev = events[i]; 626 | this._eventHandlers.push({ 627 | type: ev[0], 628 | namespace: ev[1], 629 | data: data, 630 | handler: handler 631 | }); 632 | } 633 | }, 634 | 635 | off: function(events, handler) { 636 | events = Garnish._normalizeEvents(events); 637 | 638 | for (var i = 0; i < events.length; i++) { 639 | var ev = events[i]; 640 | 641 | for (var j = this._eventHandlers.length - 1; j >= 0; j--) { 642 | var eventHandler = this._eventHandlers[j]; 643 | 644 | if ( 645 | eventHandler.type === ev[0] && 646 | (!ev[1] || eventHandler.namespace === ev[1]) && 647 | eventHandler.handler === handler 648 | ) { 649 | this._eventHandlers.splice(j, 1); 650 | } 651 | } 652 | } 653 | }, 654 | 655 | trigger: function(type, data) { 656 | var ev = { 657 | type: type, 658 | target: this 659 | }; 660 | 661 | // instance level event handlers 662 | var i, handler, _ev; 663 | for (i = 0; i < this._eventHandlers.length; i++) { 664 | handler = this._eventHandlers[i]; 665 | 666 | if (handler.type === type) { 667 | _ev = $.extend({data: handler.data}, data, ev); 668 | handler.handler(_ev); 669 | } 670 | } 671 | 672 | // class level event handlers 673 | for (i = 0; i < Garnish._eventHandlers.length; i++) { 674 | handler = Garnish._eventHandlers[i]; 675 | 676 | if (this instanceof handler.target && handler.type === type) { 677 | _ev = $.extend({data: handler.data}, data, ev); 678 | handler.handler(_ev); 679 | } 680 | } 681 | }, 682 | 683 | _splitEvents: function(events) { 684 | if (typeof events === 'string') { 685 | events = events.split(','); 686 | 687 | for (var i = 0; i < events.length; i++) { 688 | events[i] = $.trim(events[i]); 689 | } 690 | } 691 | 692 | return events; 693 | }, 694 | 695 | _formatEvents: function(events) { 696 | events = this._splitEvents(events).slice(0); 697 | 698 | for (var i = 0; i < events.length; i++) { 699 | events[i] += this._namespace; 700 | } 701 | 702 | return events.join(' '); 703 | }, 704 | 705 | addListener: function(elem, events, data, func) { 706 | var $elem = $(elem); 707 | 708 | // Ignore if there aren't any elements 709 | if (!$elem.length) { 710 | return; 711 | } 712 | 713 | events = this._splitEvents(events); 714 | 715 | // Param mapping 716 | if (typeof func === 'undefined' && typeof data !== 'object') { 717 | // (elem, events, func) 718 | func = data; 719 | data = {}; 720 | } 721 | 722 | if (typeof func === 'function') { 723 | func = func.bind(this); 724 | } 725 | else { 726 | func = this[func].bind(this); 727 | } 728 | 729 | $elem.on(this._formatEvents(events), data, $.proxy(function() { 730 | if (!this._disabled) { 731 | return func.apply(this, arguments); 732 | } 733 | }, this)); 734 | 735 | // Remember that we're listening to this element 736 | if ($.inArray(elem, this._listeners) === -1) { 737 | this._listeners.push(elem); 738 | } 739 | }, 740 | 741 | removeListener: function(elem, events) { 742 | $(elem).off(this._formatEvents(events)); 743 | }, 744 | 745 | removeAllListeners: function(elem) { 746 | $(elem).off(this._namespace); 747 | }, 748 | 749 | disable: function() { 750 | this._disabled = true; 751 | }, 752 | 753 | enable: function() { 754 | this._disabled = false; 755 | }, 756 | 757 | destroy: function() { 758 | this.trigger('destroy'); 759 | this.removeAllListeners(this._listeners); 760 | } 761 | }); 762 | 763 | // Custom events 764 | // ----------------------------------------------------------------------------- 765 | 766 | var erd; 767 | 768 | function getErd() { 769 | if (typeof erd === 'undefined') { 770 | erd = elementResizeDetectorMaker({ 771 | callOnAdd: false 772 | }); 773 | } 774 | 775 | return erd; 776 | } 777 | 778 | function triggerResizeEvent(elem) { 779 | $(elem).trigger('resize'); 780 | } 781 | 782 | // Work them into jQuery's event system 783 | $.extend(jQuery.event.special, { 784 | activate: { 785 | setup: function(data, namespaces, eventHandle) { 786 | var activateNamespace = this._namespace + '-activate'; 787 | var $elem = $(this); 788 | 789 | $elem.on({ 790 | 'mousedown.garnish-activate': function(e) { 791 | // Prevent buttons from getting focus on click 792 | e.preventDefault(); 793 | }, 794 | 'click.garnish-activate': function(e) { 795 | e.preventDefault(); 796 | 797 | if (!$elem.hasClass('disabled')) { 798 | $elem.trigger('activate'); 799 | } 800 | }, 801 | 'keydown.garnish-activate': function(e) { 802 | // Ignore if the event was bubbled up, or if it wasn't the space key 803 | if (this !== $elem[0] || e.keyCode !== Garnish.SPACE_KEY) { 804 | return; 805 | } 806 | 807 | e.preventDefault(); 808 | 809 | if (!$elem.hasClass('disabled')) { 810 | $elem.addClass('active'); 811 | 812 | Garnish.$doc.on('keyup.garnish-activate', function(e) { 813 | $elem.removeClass('active'); 814 | 815 | if (e.keyCode === Garnish.SPACE_KEY) { 816 | e.preventDefault(); 817 | $elem.trigger('activate'); 818 | } 819 | 820 | Garnish.$doc.off('keyup.garnish-activate'); 821 | }); 822 | } 823 | } 824 | }); 825 | 826 | if (!$elem.hasClass('disabled')) { 827 | $elem.attr('tabindex', '0'); 828 | } else { 829 | $elem.removeAttr('tabindex'); 830 | } 831 | }, 832 | teardown: function() { 833 | $(this).off('.garnish-activate'); 834 | } 835 | }, 836 | 837 | textchange: { 838 | setup: function(data, namespaces, eventHandle) { 839 | var $elem = $(this); 840 | $elem.data('garnish-textchange-value', $elem.val()); 841 | $elem.on('keypress.garnish-textchange keyup.garnish-textchange change.garnish-textchange blur.garnish-textchange', function(e) { 842 | var val = $elem.val(); 843 | if (val !== $elem.data('garnish-textchange-value')) { 844 | $elem.data('garnish-textchange-value', val); 845 | $elem.trigger('textchange'); 846 | } 847 | }); 848 | }, 849 | teardown: function() { 850 | $(this).off('.garnish-textchange'); 851 | }, 852 | handle: function(ev, data) { 853 | var el = this; 854 | var args = arguments; 855 | var delay = data && typeof data.delay !== 'undefined' ? data.delay : (ev.data && ev.data.delay !== undefined ? ev.data.delay : null); 856 | var handleObj = ev.handleObj; 857 | var targetData = $.data(ev.target); 858 | 859 | // Was this event configured with a delay? 860 | if (delay) { 861 | if (targetData.delayTimeout) { 862 | clearTimeout(targetData.delayTimeout); 863 | } 864 | 865 | targetData.delayTimeout = setTimeout(function() { 866 | handleObj.handler.apply(el, args); 867 | }, delay); 868 | } else { 869 | return handleObj.handler.apply(el, args); 870 | } 871 | } 872 | }, 873 | 874 | resize: { 875 | setup: function(data, namespaces, eventHandle) { 876 | // window is the only element that natively supports a resize event 877 | if (this === window) { 878 | return false; 879 | } 880 | 881 | $('> :last-child', this).addClass('last'); 882 | getErd().listenTo(this, triggerResizeEvent) 883 | }, 884 | teardown: function() { 885 | if (this === window) { 886 | return false; 887 | } 888 | 889 | getErd().removeListener(this, triggerResizeEvent); 890 | } 891 | } 892 | }); 893 | 894 | // Give them their own element collection chaining methods 895 | jQuery.each(['activate', 'textchange', 'resize'], function(i, name) { 896 | jQuery.fn[name] = function(data, fn) { 897 | return arguments.length > 0 ? 898 | this.on(name, null, data, fn) : 899 | this.trigger(name); 900 | }; 901 | }); 902 | -------------------------------------------------------------------------------- /src/HUD.js: -------------------------------------------------------------------------------- 1 | /** global: Garnish */ 2 | /** 3 | * HUD 4 | */ 5 | Garnish.HUD = Garnish.Base.extend( 6 | { 7 | $trigger: null, 8 | $fixedTriggerParent: null, 9 | $hud: null, 10 | $tip: null, 11 | $body: null, 12 | $header: null, 13 | $footer: null, 14 | $mainContainer: null, 15 | $main: null, 16 | $shade: null, 17 | 18 | showing: false, 19 | orientation: null, 20 | 21 | updatingSizeAndPosition: false, 22 | windowWidth: null, 23 | windowHeight: null, 24 | scrollTop: null, 25 | scrollLeft: null, 26 | mainWidth: null, 27 | mainHeight: null, 28 | 29 | /** 30 | * Constructor 31 | */ 32 | init: function(trigger, bodyContents, settings) { 33 | 34 | this.$trigger = $(trigger); 35 | 36 | this.setSettings(settings, Garnish.HUD.defaults); 37 | this.on('show', this.settings.onShow); 38 | this.on('hide', this.settings.onHide); 39 | this.on('submit', this.settings.onSubmit); 40 | 41 | if (typeof Garnish.HUD.activeHUDs === 'undefined') { 42 | Garnish.HUD.activeHUDs = {}; 43 | } 44 | 45 | this.$shade = $('', {'class': this.settings.shadeClass}); 46 | this.$hud = $('', {'class': this.settings.hudClass}).data('hud', this); 47 | this.$tip = $('', {'class': this.settings.tipClass}).appendTo(this.$hud); 48 | this.$body = $('', {'class': this.settings.bodyClass}).appendTo(this.$hud); 49 | this.$mainContainer = $('', {'class': this.settings.mainContainerClass}).appendTo(this.$body); 50 | this.$main = $('', {'class': this.settings.mainClass}).appendTo(this.$mainContainer); 51 | 52 | this.updateBody(bodyContents); 53 | 54 | // See if the trigger is fixed 55 | var $parent = this.$trigger; 56 | 57 | do { 58 | if ($parent.css('position') === 'fixed') { 59 | this.$fixedTriggerParent = $parent; 60 | break; 61 | } 62 | 63 | $parent = $parent.offsetParent(); 64 | } 65 | while ($parent.length && $parent.prop('nodeName') !== 'HTML'); 66 | 67 | if (this.$fixedTriggerParent) { 68 | this.$hud.css('position', 'fixed'); 69 | } 70 | else { 71 | this.$hud.css('position', 'absolute'); 72 | } 73 | 74 | // Hide the HUD until it gets positioned 75 | this.$hud.css('opacity', 0); 76 | this.show(); 77 | this.$hud.css('opacity', 1); 78 | 79 | this.addListener(this.$body, 'submit', '_handleSubmit'); 80 | 81 | if (this.settings.hideOnShadeClick) { 82 | this.addListener(this.$shade, 'tap,click', 'hide'); 83 | } 84 | 85 | if (this.settings.closeBtn) { 86 | this.addListener(this.settings.closeBtn, 'activate', 'hide'); 87 | } 88 | 89 | this.addListener(Garnish.$win, 'resize', 'updateSizeAndPosition'); 90 | this.addListener(this.$main, 'resize', 'updateSizeAndPosition'); 91 | if (!this.$fixedTriggerParent && Garnish.$scrollContainer[0] !== Garnish.$win[0]) { 92 | this.addListener(Garnish.$scrollContainer, 'scroll', 'updateSizeAndPosition'); 93 | } 94 | }, 95 | 96 | /** 97 | * Update the body contents 98 | */ 99 | updateBody: function(bodyContents) { 100 | // Cleanup 101 | this.$main.html(''); 102 | 103 | if (this.$header) { 104 | this.$hud.removeClass('has-header'); 105 | this.$header.remove(); 106 | this.$header = null; 107 | } 108 | 109 | if (this.$footer) { 110 | this.$hud.removeClass('has-footer'); 111 | this.$footer.remove(); 112 | this.$footer = null; 113 | } 114 | 115 | // Append the new body contents 116 | this.$main.append(bodyContents); 117 | 118 | // Look for a header and footer 119 | var $header = this.$main.find('.' + this.settings.headerClass + ':first'), 120 | $footer = this.$main.find('.' + this.settings.footerClass + ':first'); 121 | 122 | if ($header.length) { 123 | this.$header = $header.insertBefore(this.$mainContainer); 124 | this.$hud.addClass('has-header'); 125 | } 126 | 127 | if ($footer.length) { 128 | this.$footer = $footer.insertAfter(this.$mainContainer); 129 | this.$hud.addClass('has-footer'); 130 | } 131 | }, 132 | 133 | /** 134 | * Show 135 | */ 136 | show: function(ev) { 137 | if (ev && ev.stopPropagation) { 138 | ev.stopPropagation(); 139 | } 140 | 141 | if (this.showing) { 142 | return; 143 | } 144 | 145 | if (this.settings.closeOtherHUDs) { 146 | for (var hudID in Garnish.HUD.activeHUDs) { 147 | if (!Garnish.HUD.activeHUDs.hasOwnProperty(hudID)) { 148 | continue; 149 | } 150 | Garnish.HUD.activeHUDs[hudID].hide(); 151 | } 152 | } 153 | 154 | // Move it to the end of so it gets the highest sub-z-index 155 | this.$shade.appendTo(Garnish.$bod); 156 | this.$hud.appendTo(Garnish.$bod); 157 | 158 | this.$hud.show(); 159 | this.$shade.show(); 160 | this.showing = true; 161 | Garnish.HUD.activeHUDs[this._namespace] = this; 162 | 163 | Garnish.shortcutManager.addLayer(); 164 | 165 | if (this.settings.hideOnEsc) { 166 | Garnish.shortcutManager.registerShortcut(Garnish.ESC_KEY, this.hide.bind(this)); 167 | } 168 | 169 | this.onShow(); 170 | this.enable(); 171 | 172 | if (this.updateRecords()) { 173 | // Prevent the browser from jumping 174 | this.$hud.css('top', Garnish.$scrollContainer.scrollTop()); 175 | 176 | this.updateSizeAndPosition(true); 177 | } 178 | }, 179 | 180 | onShow: function() { 181 | this.trigger('show'); 182 | }, 183 | 184 | updateRecords: function() { 185 | var changed = false; 186 | changed = (this.windowWidth !== (this.windowWidth = Garnish.$win.width())) || changed; 187 | changed = (this.windowHeight !== (this.windowHeight = Garnish.$win.height())) || changed; 188 | changed = (this.scrollTop !== (this.scrollTop = Garnish.$scrollContainer.scrollTop())) || changed; 189 | changed = (this.scrollLeft !== (this.scrollLeft = Garnish.$scrollContainer.scrollLeft())) || changed; 190 | changed = (this.mainWidth !== (this.mainWidth = this.$main.outerWidth())) || changed; 191 | changed = (this.mainHeight !== (this.mainHeight = this.$main.outerHeight())) || changed; 192 | return changed; 193 | }, 194 | 195 | updateSizeAndPosition: function(force) { 196 | if (force === true || (this.updateRecords() && !this.updatingSizeAndPosition)) { 197 | this.updatingSizeAndPosition = true; 198 | Garnish.requestAnimationFrame(this.updateSizeAndPositionInternal.bind(this)); 199 | } 200 | }, 201 | 202 | updateSizeAndPositionInternal: function() { 203 | var triggerWidth, 204 | triggerHeight, 205 | triggerOffset, 206 | windowScrollLeft, 207 | windowScrollTop, 208 | scrollContainerTriggerOffset, 209 | scrollContainerScrollLeft, 210 | scrollContainerScrollTop, 211 | hudBodyWidth, 212 | hudBodyHeight; 213 | 214 | // Get the window sizes and trigger offset 215 | 216 | windowScrollLeft = Garnish.$win.scrollLeft(); 217 | windowScrollTop = Garnish.$win.scrollTop(); 218 | 219 | // Get the trigger's dimensions 220 | triggerWidth = this.$trigger.outerWidth(); 221 | triggerHeight = this.$trigger.outerHeight(); 222 | 223 | // Get the offsets for each side of the trigger element 224 | triggerOffset = this.$trigger.offset(); 225 | 226 | if (this.$fixedTriggerParent) { 227 | triggerOffset.left -= windowScrollLeft; 228 | triggerOffset.top -= windowScrollTop; 229 | 230 | scrollContainerTriggerOffset = triggerOffset; 231 | 232 | windowScrollLeft = 0; 233 | windowScrollTop = 0; 234 | scrollContainerScrollLeft = 0; 235 | scrollContainerScrollTop = 0; 236 | } 237 | else { 238 | scrollContainerTriggerOffset = Garnish.getOffset(this.$trigger); 239 | 240 | scrollContainerScrollLeft = Garnish.$scrollContainer.scrollLeft(); 241 | scrollContainerScrollTop = Garnish.$scrollContainer.scrollTop(); 242 | } 243 | 244 | triggerOffset.right = triggerOffset.left + triggerWidth; 245 | triggerOffset.bottom = triggerOffset.top + triggerHeight; 246 | 247 | scrollContainerTriggerOffset.right = scrollContainerTriggerOffset.left + triggerWidth; 248 | scrollContainerTriggerOffset.bottom = scrollContainerTriggerOffset.top + triggerHeight; 249 | 250 | // Get the HUD dimensions 251 | this.$hud.css({ 252 | width: '' 253 | }); 254 | 255 | this.$mainContainer.css({ 256 | height: '', 257 | 'overflow-x': '', 258 | 'overflow-y': '' 259 | }); 260 | 261 | hudBodyWidth = this.$body.width(); 262 | hudBodyHeight = this.$body.height(); 263 | 264 | // Determine the best orientation for the HUD 265 | 266 | // Find the actual available top/right/bottom/left clearances 267 | var clearances = { 268 | bottom: this.windowHeight + scrollContainerScrollTop - scrollContainerTriggerOffset.bottom, 269 | top: scrollContainerTriggerOffset.top - scrollContainerScrollTop, 270 | right: this.windowWidth + scrollContainerScrollLeft - scrollContainerTriggerOffset.right, 271 | left: scrollContainerTriggerOffset.left - scrollContainerScrollLeft 272 | }; 273 | 274 | // Find the first position that has enough room 275 | this.orientation = null; 276 | 277 | for (var i = 0; i < this.settings.orientations.length; i++) { 278 | var orientation = this.settings.orientations[i], 279 | relevantSize = (orientation === 'top' || orientation === 'bottom' ? hudBodyHeight : hudBodyWidth); 280 | 281 | if (clearances[orientation] - (this.settings.windowSpacing + this.settings.triggerSpacing) >= relevantSize) { 282 | // This is the first orientation that has enough room in order of preference, so we'll go with this 283 | this.orientation = orientation; 284 | break; 285 | } 286 | 287 | if (!this.orientation || clearances[orientation] > clearances[this.orientation]) { 288 | // Use this as a fallback as it's the orientation with the most clearance so far 289 | this.orientation = orientation; 290 | } 291 | } 292 | 293 | // Just in case... 294 | if (!this.orientation || $.inArray(this.orientation, ['bottom', 'top', 'right', 'left']) === -1) { 295 | this.orientation = 'bottom' 296 | } 297 | 298 | // Update the tip class 299 | if (this.tipClass) { 300 | this.$tip.removeClass(this.tipClass); 301 | } 302 | 303 | this.tipClass = this.settings.tipClass + '-' + Garnish.HUD.tipClasses[this.orientation]; 304 | this.$tip.addClass(this.tipClass); 305 | 306 | // Make sure the HUD body is within the allowed size 307 | 308 | var maxHudBodyWidth, 309 | maxHudBodyHeight; 310 | 311 | if (this.orientation === 'top' || this.orientation === 'bottom') { 312 | maxHudBodyWidth = this.windowWidth - this.settings.windowSpacing * 2; 313 | maxHudBodyHeight = clearances[this.orientation] - this.settings.windowSpacing - this.settings.triggerSpacing; 314 | } 315 | else { 316 | maxHudBodyWidth = clearances[this.orientation] - this.settings.windowSpacing - this.settings.triggerSpacing; 317 | maxHudBodyHeight = this.windowHeight - this.settings.windowSpacing * 2; 318 | } 319 | 320 | if (maxHudBodyWidth < this.settings.minBodyWidth) { 321 | maxHudBodyWidth = this.settings.minBodyWidth; 322 | } 323 | 324 | if (maxHudBodyHeight < this.settings.minBodyHeight) { 325 | maxHudBodyHeight = this.settings.minBodyHeight; 326 | } 327 | 328 | if (hudBodyWidth > maxHudBodyWidth || hudBodyWidth < this.settings.minBodyWidth) { 329 | if (hudBodyWidth > maxHudBodyWidth) { 330 | hudBodyWidth = maxHudBodyWidth; 331 | } 332 | else { 333 | hudBodyWidth = this.settings.minBodyWidth; 334 | } 335 | 336 | this.$hud.width(hudBodyWidth); 337 | 338 | // Is there any overflow now? 339 | if (this.mainWidth > maxHudBodyWidth) { 340 | this.$mainContainer.css('overflow-x', 'scroll'); 341 | } 342 | 343 | // The height may have just changed 344 | hudBodyHeight = this.$body.height(); 345 | } 346 | 347 | if (hudBodyHeight > maxHudBodyHeight || hudBodyHeight < this.settings.minBodyHeight) { 348 | if (hudBodyHeight > maxHudBodyHeight) { 349 | hudBodyHeight = maxHudBodyHeight; 350 | } 351 | else { 352 | hudBodyHeight = this.settings.minBodyHeight; 353 | } 354 | 355 | var mainHeight = hudBodyHeight; 356 | 357 | if (this.$header) { 358 | mainHeight -= this.$header.outerHeight(); 359 | } 360 | 361 | if (this.$footer) { 362 | mainHeight -= this.$footer.outerHeight(); 363 | } 364 | 365 | this.$mainContainer.height(mainHeight); 366 | 367 | // Is there any overflow now? 368 | if (this.mainHeight > mainHeight) { 369 | this.$mainContainer.css('overflow-y', 'scroll'); 370 | } 371 | } 372 | 373 | // Set the HUD/tip positions 374 | var triggerCenter, left, top; 375 | 376 | if (this.orientation === 'top' || this.orientation === 'bottom') { 377 | // Center the HUD horizontally 378 | var maxLeft = (this.windowWidth + windowScrollLeft) - (hudBodyWidth + this.settings.windowSpacing); 379 | var minLeft = (windowScrollLeft + this.settings.windowSpacing); 380 | triggerCenter = triggerOffset.left + Math.round(triggerWidth / 2); 381 | left = triggerCenter - Math.round(hudBodyWidth / 2); 382 | 383 | if (left > maxLeft) { 384 | left = maxLeft; 385 | } 386 | if (left < minLeft) { 387 | left = minLeft; 388 | } 389 | 390 | this.$hud.css('left', left); 391 | 392 | var tipLeft = (triggerCenter - left) - (this.settings.tipWidth / 2); 393 | this.$tip.css({left: tipLeft, top: ''}); 394 | 395 | if (this.orientation === 'top') { 396 | top = triggerOffset.top - (hudBodyHeight + this.settings.triggerSpacing); 397 | this.$hud.css('top', top); 398 | } 399 | else { 400 | top = triggerOffset.bottom + this.settings.triggerSpacing; 401 | this.$hud.css('top', top); 402 | } 403 | } 404 | else { 405 | // Center the HUD vertically 406 | var maxTop = (this.windowHeight + windowScrollTop) - (hudBodyHeight + this.settings.windowSpacing); 407 | var minTop = (windowScrollTop + this.settings.windowSpacing); 408 | triggerCenter = triggerOffset.top + Math.round(triggerHeight / 2); 409 | top = triggerCenter - Math.round(hudBodyHeight / 2); 410 | 411 | if (top > maxTop) { 412 | top = maxTop; 413 | } 414 | if (top < minTop) { 415 | top = minTop; 416 | } 417 | 418 | this.$hud.css('top', top); 419 | 420 | var tipTop = (triggerCenter - top) - (this.settings.tipWidth / 2); 421 | this.$tip.css({top: tipTop, left: ''}); 422 | 423 | 424 | if (this.orientation === 'left') { 425 | left = triggerOffset.left - (hudBodyWidth + this.settings.triggerSpacing); 426 | this.$hud.css('left', left); 427 | } 428 | else { 429 | left = triggerOffset.right + this.settings.triggerSpacing; 430 | this.$hud.css('left', left); 431 | } 432 | } 433 | 434 | this.updatingSizeAndPosition = false; 435 | this.trigger('updateSizeAndPosition'); 436 | }, 437 | 438 | /** 439 | * Hide 440 | */ 441 | hide: function() { 442 | if (!this.showing) { 443 | return; 444 | } 445 | 446 | this.disable(); 447 | 448 | this.$hud.hide(); 449 | this.$shade.hide(); 450 | 451 | this.showing = false; 452 | delete Garnish.HUD.activeHUDs[this._namespace]; 453 | Garnish.shortcutManager.removeLayer(); 454 | this.onHide(); 455 | }, 456 | 457 | onHide: function() { 458 | this.trigger('hide'); 459 | }, 460 | 461 | toggle: function() { 462 | if (this.showing) { 463 | this.hide(); 464 | } 465 | else { 466 | this.show(); 467 | } 468 | }, 469 | 470 | submit: function() { 471 | this.onSubmit(); 472 | }, 473 | 474 | onSubmit: function() { 475 | this.trigger('submit'); 476 | }, 477 | 478 | _handleSubmit: function(ev) { 479 | ev.preventDefault(); 480 | this.submit(); 481 | }, 482 | 483 | /** 484 | * Destroy 485 | */ 486 | destroy: function() { 487 | if (this.$hud) { 488 | this.$hud.remove(); 489 | } 490 | 491 | if (this.$shade) { 492 | this.$shade.remove(); 493 | } 494 | 495 | this.base(); 496 | } 497 | }, 498 | { 499 | tipClasses: {bottom: 'top', top: 'bottom', right: 'left', left: 'right'}, 500 | 501 | defaults: { 502 | shadeClass: 'hud-shade', 503 | hudClass: 'hud', 504 | tipClass: 'tip', 505 | bodyClass: 'body', 506 | headerClass: 'hud-header', 507 | footerClass: 'hud-footer', 508 | mainContainerClass: 'main-container', 509 | mainClass: 'main', 510 | orientations: ['bottom', 'top', 'right', 'left'], 511 | triggerSpacing: 10, 512 | windowSpacing: 10, 513 | tipWidth: 30, 514 | minBodyWidth: 200, 515 | minBodyHeight: 0, 516 | onShow: $.noop, 517 | onHide: $.noop, 518 | onSubmit: $.noop, 519 | closeBtn: null, 520 | closeOtherHUDs: true, 521 | hideOnEsc: true, 522 | hideOnShadeClick: true, 523 | } 524 | } 525 | ); 526 | -------------------------------------------------------------------------------- /src/MenuBtn.js: -------------------------------------------------------------------------------- 1 | /** global: Garnish */ 2 | /** 3 | * Menu Button 4 | */ 5 | Garnish.MenuBtn = Garnish.Base.extend( 6 | { 7 | $btn: null, 8 | menu: null, 9 | showingMenu: false, 10 | disabled: true, 11 | 12 | /** 13 | * Constructor 14 | */ 15 | init: function(btn, menu, settings) { 16 | // Param mapping 17 | if (typeof settings === 'undefined' && $.isPlainObject(menu)) { 18 | // (btn, settings) 19 | settings = menu; 20 | menu = null; 21 | } 22 | 23 | this.$btn = $(btn); 24 | var $menu; 25 | 26 | // Is this already a menu button? 27 | if (this.$btn.data('menubtn')) { 28 | // Grab the old MenuBtn's menu container 29 | if (!menu) { 30 | $menu = this.$btn.data('menubtn').menu.$container; 31 | } 32 | 33 | Garnish.log('Double-instantiating a menu button on an element'); 34 | this.$btn.data('menubtn').destroy(); 35 | } 36 | else if (!menu) { 37 | $menu = this.$btn.next('.menu').detach(); 38 | } 39 | 40 | this.$btn.data('menubtn', this); 41 | 42 | this.setSettings(settings, Garnish.MenuBtn.defaults); 43 | 44 | this.menu = menu || new Garnish.CustomSelect($menu); 45 | this.menu.$anchor = $(this.settings.menuAnchor || this.$btn); 46 | this.menu.on('optionselect', function(ev) { 47 | this.onOptionSelect(ev.selectedOption); 48 | }.bind(this)); 49 | 50 | this.$btn.attr({ 51 | 'tabindex': 0, 52 | 'aria-controls': this.menu.menuId, 53 | 'aria-haspopup': 'listbox', 54 | 'aria-expanded': 'false' 55 | }); 56 | 57 | this.menu.on('hide', this.onMenuHide.bind(this)); 58 | this.addListener(this.$btn, 'mousedown', 'onMouseDown'); 59 | this.addListener(this.$btn, 'keydown', 'onKeyDown'); 60 | this.addListener(this.$btn, 'blur', 'onBlur'); 61 | this.enable(); 62 | }, 63 | 64 | onBlur: function() { 65 | if (this.showingMenu) { 66 | Garnish.requestAnimationFrame(function() { 67 | if (!$.contains(this.menu.$container.get(0), document.activeElement)) { 68 | this.hideMenu(); 69 | } 70 | }.bind(this)); 71 | } 72 | }, 73 | 74 | onKeyDown: function(ev) { 75 | var $option; 76 | 77 | switch (ev.keyCode) { 78 | case Garnish.RETURN_KEY: { 79 | ev.preventDefault(); 80 | 81 | const $currentOption = this.menu.$options.filter('.hover'); 82 | if ($currentOption.length > 0) { 83 | $currentOption.get(0).click(); 84 | } 85 | 86 | break; 87 | } 88 | 89 | case Garnish.SPACE_KEY: { 90 | ev.preventDefault(); 91 | 92 | if (this.showingMenu) { 93 | const $currentOption = this.menu.$options.filter('.hover'); 94 | if ($currentOption.length > 0) { 95 | $currentOption.get(0).click(); 96 | } 97 | } else { 98 | this.showMenu(); 99 | 100 | $option = this.menu.$options.filter('.sel:first'); 101 | 102 | if ($option.length === 0) { 103 | $option = this.menu.$options.first(); 104 | } 105 | 106 | this.focusOption($option); 107 | } 108 | 109 | break; 110 | } 111 | 112 | case Garnish.DOWN_KEY: { 113 | ev.preventDefault(); 114 | 115 | if (this.showingMenu) { 116 | $.each(this.menu.$options, function(index, value) { 117 | if (!$option) { 118 | if ($(value).hasClass('hover')) { 119 | if ((index + 1) < this.menu.$options.length) { 120 | $option = $(this.menu.$options[(index + 1)]); 121 | } 122 | } 123 | } 124 | }.bind(this)); 125 | 126 | if (!$option) { 127 | $option = $(this.menu.$options[0]); 128 | } 129 | } 130 | else { 131 | this.showMenu(); 132 | 133 | $option = this.menu.$options.filter('.sel:first'); 134 | 135 | if ($option.length === 0) { 136 | $option = this.menu.$options.first(); 137 | } 138 | } 139 | 140 | this.focusOption($option); 141 | 142 | break; 143 | } 144 | 145 | case Garnish.UP_KEY: { 146 | ev.preventDefault(); 147 | 148 | if (this.showingMenu) { 149 | $.each(this.menu.$options, function(index, value) { 150 | if (!$option) { 151 | if ($(value).hasClass('hover')) { 152 | if ((index - 1) >= 0) { 153 | $option = $(this.menu.$options[(index - 1)]); 154 | } 155 | } 156 | } 157 | }.bind(this)); 158 | 159 | if (!$option) { 160 | $option = $(this.menu.$options[(this.menu.$options.length - 1)]); 161 | } 162 | } 163 | else { 164 | this.showMenu(); 165 | 166 | $option = this.menu.$options.filter('.sel:first'); 167 | 168 | if ($option.length === 0) { 169 | $option = this.menu.$options.last(); 170 | } 171 | } 172 | 173 | this.focusOption($option); 174 | 175 | break; 176 | } 177 | } 178 | }, 179 | 180 | focusOption: function($option) { 181 | this.menu.$options.removeClass('hover'); 182 | 183 | $option.addClass('hover'); 184 | 185 | this.menu.$menuList.attr('aria-activedescendant', $option.attr('id')); 186 | this.$btn.attr('aria-activedescendant', $option.attr('id')); 187 | }, 188 | 189 | onMouseDown: function(ev) { 190 | if (ev.which !== Garnish.PRIMARY_CLICK || Garnish.isCtrlKeyPressed(ev) || ev.target.nodeName === 'INPUT') { 191 | return; 192 | } 193 | 194 | ev.preventDefault(); 195 | 196 | if (this.showingMenu) { 197 | this.hideMenu(); 198 | } 199 | else { 200 | this.showMenu(); 201 | } 202 | }, 203 | 204 | showMenu: function() { 205 | if (this.disabled) { 206 | return; 207 | } 208 | 209 | this.menu.show(); 210 | this.$btn.addClass('active'); 211 | this.$btn.trigger('focus'); 212 | this.$btn.attr('aria-expanded', 'true'); 213 | 214 | this.showingMenu = true; 215 | 216 | setTimeout(function() { 217 | this.addListener(Garnish.$doc, 'mousedown', 'onMouseDown'); 218 | }.bind(this), 1); 219 | }, 220 | 221 | hideMenu: function() { 222 | this.menu.hide(); 223 | }, 224 | 225 | onMenuHide: function() { 226 | this.$btn.removeClass('active'); 227 | this.$btn.attr('aria-expanded', 'false'); 228 | this.showingMenu = false; 229 | 230 | this.removeListener(Garnish.$doc, 'mousedown'); 231 | }, 232 | 233 | onOptionSelect: function(option) { 234 | this.settings.onOptionSelect(option); 235 | this.trigger('optionSelect', {option: option}); 236 | }, 237 | 238 | enable: function() { 239 | this.disabled = false; 240 | }, 241 | 242 | disable: function() { 243 | this.disabled = true; 244 | }, 245 | 246 | /** 247 | * Destroy 248 | */ 249 | destroy: function() { 250 | this.$btn.removeData('menubtn'); 251 | this.base(); 252 | } 253 | }, 254 | { 255 | defaults: { 256 | menuAnchor: null, 257 | onOptionSelect: $.noop 258 | } 259 | } 260 | ); 261 | -------------------------------------------------------------------------------- /src/MixedInput.js: -------------------------------------------------------------------------------- 1 | /** global: Garnish */ 2 | /** 3 | * Mixed input 4 | * 5 | * @todo RTL support, in the event that the input doesn't have dir="ltr". 6 | */ 7 | Garnish.MixedInput = Garnish.Base.extend( 8 | { 9 | $container: null, 10 | elements: null, 11 | focussedElement: null, 12 | blurTimeout: null, 13 | 14 | init: function(container, settings) { 15 | this.$container = $(container); 16 | this.setSettings(settings, Garnish.MixedInput.defaults); 17 | 18 | this.elements = []; 19 | 20 | // Allow the container to receive focus 21 | this.$container.attr('tabindex', 0); 22 | this.addListener(this.$container, 'focus', 'onFocus'); 23 | }, 24 | 25 | getElementIndex: function($elem) { 26 | return $.inArray($elem, this.elements); 27 | }, 28 | 29 | isText: function($elem) { 30 | return ($elem.prop('nodeName') === 'INPUT'); 31 | }, 32 | 33 | onFocus: function() { 34 | // Set focus to the first element 35 | if (this.elements.length) { 36 | var $elem = this.elements[0]; 37 | this.setFocus($elem); 38 | this.setCarotPos($elem, 0); 39 | } 40 | else { 41 | this.addTextElement(); 42 | } 43 | }, 44 | 45 | addTextElement: function(index) { 46 | var text = new TextElement(this); 47 | this.addElement(text.$input, index); 48 | return text; 49 | }, 50 | 51 | addElement: function($elem, index) { 52 | // Was a target index passed, and is it valid? 53 | if (typeof index === 'undefined') { 54 | if (this.focussedElement) { 55 | var focussedElement = this.focussedElement, 56 | focussedElementIndex = this.getElementIndex(focussedElement); 57 | 58 | // Is the focus on a text element? 59 | if (this.isText(focussedElement)) { 60 | var selectionStart = focussedElement.prop('selectionStart'), 61 | selectionEnd = focussedElement.prop('selectionEnd'), 62 | val = focussedElement.val(), 63 | preVal = val.substring(0, selectionStart), 64 | postVal = val.substr(selectionEnd); 65 | 66 | if (preVal && postVal) { 67 | // Split the input into two 68 | focussedElement.val(preVal).trigger('change'); 69 | var newText = new TextElement(this); 70 | newText.$input.val(postVal).trigger('change'); 71 | this.addElement(newText.$input, focussedElementIndex + 1); 72 | 73 | // Insert the new element in between them 74 | index = focussedElementIndex + 1; 75 | } 76 | else if (!preVal) { 77 | // Insert the new element before this one 78 | index = focussedElementIndex; 79 | } 80 | else { 81 | // Insert it after this one 82 | index = focussedElementIndex + 1; 83 | } 84 | } 85 | else { 86 | // Just insert the new one after this one 87 | index = focussedElementIndex + 1; 88 | } 89 | } 90 | else { 91 | // Insert the new element at the end 92 | index = this.elements.length; 93 | } 94 | } 95 | 96 | // Add the element 97 | if (typeof this.elements[index] !== 'undefined') { 98 | $elem.insertBefore(this.elements[index]); 99 | this.elements.splice(index, 0, $elem); 100 | } 101 | else { 102 | // Just for safe measure, set the index to what it really will be 103 | index = this.elements.length; 104 | 105 | this.$container.append($elem); 106 | this.elements.push($elem); 107 | } 108 | 109 | // Make sure that there are text elements surrounding all non-text elements 110 | if (!this.isText($elem)) { 111 | // Add a text element before? 112 | if (index === 0 || !this.isText(this.elements[index - 1])) { 113 | this.addTextElement(index); 114 | index++; 115 | } 116 | 117 | // Add a text element after? 118 | if (index === this.elements.length - 1 || !this.isText(this.elements[index + 1])) { 119 | this.addTextElement(index + 1); 120 | } 121 | } 122 | 123 | // Add event listeners 124 | this.addListener($elem, 'click', function() { 125 | this.setFocus($elem); 126 | }); 127 | 128 | // Set focus to the new element 129 | setTimeout(function() { 130 | this.setFocus($elem); 131 | }.bind(this), 1); 132 | }, 133 | 134 | removeElement: function($elem) { 135 | var index = this.getElementIndex($elem); 136 | if (index !== -1) { 137 | this.elements.splice(index, 1); 138 | 139 | if (!this.isText($elem)) { 140 | // Combine the two now-adjacent text elements 141 | var $prevElem = this.elements[index - 1], 142 | $nextElem = this.elements[index]; 143 | 144 | if (this.isText($prevElem) && this.isText($nextElem)) { 145 | var prevElemVal = $prevElem.val(), 146 | newVal = prevElemVal + $nextElem.val(); 147 | $prevElem.val(newVal).trigger('change'); 148 | this.removeElement($nextElem); 149 | this.setFocus($prevElem); 150 | this.setCarotPos($prevElem, prevElemVal.length); 151 | } 152 | } 153 | 154 | $elem.remove(); 155 | } 156 | }, 157 | 158 | setFocus: function($elem) { 159 | this.$container.addClass('focus'); 160 | 161 | if (!this.focussedElement) { 162 | // Prevent the container from receiving focus 163 | // as long as one of its elements has focus 164 | this.$container.attr('tabindex', '-1'); 165 | } 166 | else { 167 | // Blur the previously-focussed element 168 | this.blurFocussedElement(); 169 | } 170 | 171 | $elem.attr('tabindex', '0'); 172 | $elem.focus(); 173 | this.focussedElement = $elem; 174 | 175 | this.addListener($elem, 'blur', function() { 176 | this.blurTimeout = setTimeout(function() { 177 | if (this.focussedElement === $elem) { 178 | this.blurFocussedElement(); 179 | this.focussedElement = null; 180 | this.$container.removeClass('focus'); 181 | 182 | // Get ready for future focus 183 | this.$container.attr('tabindex', '0'); 184 | } 185 | }.bind(this), 1); 186 | }); 187 | }, 188 | 189 | blurFocussedElement: function() { 190 | this.removeListener(this.focussedElement, 'blur'); 191 | this.focussedElement.attr('tabindex', '-1'); 192 | }, 193 | 194 | focusPreviousElement: function($from) { 195 | var index = this.getElementIndex($from); 196 | 197 | if (index > 0) { 198 | var $elem = this.elements[index - 1]; 199 | this.setFocus($elem); 200 | 201 | // If it's a text element, put the carot at the end 202 | if (this.isText($elem)) { 203 | var length = $elem.val().length; 204 | this.setCarotPos($elem, length); 205 | } 206 | } 207 | }, 208 | 209 | focusNextElement: function($from) { 210 | var index = this.getElementIndex($from); 211 | 212 | if (index < this.elements.length - 1) { 213 | var $elem = this.elements[index + 1]; 214 | this.setFocus($elem); 215 | 216 | // If it's a text element, put the carot at the beginning 217 | if (this.isText($elem)) { 218 | this.setCarotPos($elem, 0) 219 | } 220 | } 221 | }, 222 | 223 | setCarotPos: function($elem, pos) { 224 | $elem.prop('selectionStart', pos); 225 | $elem.prop('selectionEnd', pos); 226 | } 227 | 228 | }); 229 | 230 | 231 | var TextElement = Garnish.Base.extend({ 232 | 233 | parentInput: null, 234 | $input: null, 235 | $stage: null, 236 | val: null, 237 | focussed: false, 238 | interval: null, 239 | 240 | init: function(parentInput) { 241 | this.parentInput = parentInput; 242 | 243 | this.$input = $('').appendTo(this.parentInput.$container); 244 | this.$input.css('margin-right', (2 - TextElement.padding) + 'px'); 245 | 246 | this.setWidth(); 247 | 248 | this.addListener(this.$input, 'focus', 'onFocus'); 249 | this.addListener(this.$input, 'blur', 'onBlur'); 250 | this.addListener(this.$input, 'keydown', 'onKeyDown'); 251 | this.addListener(this.$input, 'change', 'checkInput'); 252 | }, 253 | 254 | getIndex: function() { 255 | return this.parentInput.getElementIndex(this.$input); 256 | }, 257 | 258 | buildStage: function() { 259 | this.$stage = $('').appendTo(Garnish.$bod); 260 | 261 | // replicate the textarea's text styles 262 | this.$stage.css({ 263 | position: 'absolute', 264 | top: -9999, 265 | left: -9999, 266 | wordWrap: 'nowrap' 267 | }); 268 | 269 | Garnish.copyTextStyles(this.$input, this.$stage); 270 | }, 271 | 272 | getTextWidth: function(val) { 273 | if (!this.$stage) { 274 | this.buildStage(); 275 | } 276 | 277 | if (val) { 278 | // Ampersand entities 279 | val = val.replace(/&/g, '&'); 280 | 281 | // < and > 282 | val = val.replace(//g, '>'); 284 | 285 | // Spaces 286 | val = val.replace(/ /g, ' '); 287 | } 288 | 289 | this.$stage.html(val); 290 | this.stageWidth = this.$stage.width(); 291 | return this.stageWidth; 292 | }, 293 | 294 | onFocus: function() { 295 | this.focussed = true; 296 | this.interval = setInterval(this.checkInput.bind(this), Garnish.NiceText.interval); 297 | this.checkInput(); 298 | }, 299 | 300 | onBlur: function() { 301 | this.focussed = false; 302 | clearInterval(this.interval); 303 | this.checkInput(); 304 | }, 305 | 306 | onKeyDown: function(ev) { 307 | setTimeout(this.checkInput.bind(this), 1); 308 | 309 | switch (ev.keyCode) { 310 | case Garnish.LEFT_KEY: { 311 | if (this.$input.prop('selectionStart') === 0 && this.$input.prop('selectionEnd') === 0) { 312 | // Set focus to the previous element 313 | this.parentInput.focusPreviousElement(this.$input); 314 | } 315 | break; 316 | } 317 | 318 | case Garnish.RIGHT_KEY: { 319 | if (this.$input.prop('selectionStart') === this.val.length && this.$input.prop('selectionEnd') === this.val.length) { 320 | // Set focus to the next element 321 | this.parentInput.focusNextElement(this.$input); 322 | } 323 | break; 324 | } 325 | 326 | case Garnish.DELETE_KEY: { 327 | if (this.$input.prop('selectionStart') === 0 && this.$input.prop('selectionEnd') === 0) { 328 | // Set focus to the previous element 329 | this.parentInput.focusPreviousElement(this.$input); 330 | ev.preventDefault(); 331 | } 332 | } 333 | } 334 | }, 335 | 336 | getVal: function() { 337 | this.val = this.$input.val(); 338 | return this.val; 339 | }, 340 | 341 | setVal: function(val) { 342 | this.$input.val(val); 343 | this.checkInput(); 344 | }, 345 | 346 | checkInput: function() { 347 | // Has the value changed? 348 | var changed = (this.val !== this.getVal()); 349 | if (changed) { 350 | this.setWidth(); 351 | this.onChange(); 352 | } 353 | 354 | return changed; 355 | }, 356 | 357 | setWidth: function() { 358 | // has the width changed? 359 | if (this.stageWidth !== this.getTextWidth(this.val)) { 360 | // update the textarea width 361 | var width = this.stageWidth + TextElement.padding; 362 | this.$input.width(width); 363 | } 364 | }, 365 | 366 | onChange: $.noop 367 | }, 368 | { 369 | padding: 20 370 | } 371 | ); 372 | -------------------------------------------------------------------------------- /src/Modal.js: -------------------------------------------------------------------------------- 1 | /** global: Garnish */ 2 | /** 3 | * Modal 4 | */ 5 | Garnish.Modal = Garnish.Base.extend( 6 | { 7 | $container: null, 8 | $shade: null, 9 | 10 | visible: false, 11 | 12 | dragger: null, 13 | 14 | desiredWidth: null, 15 | desiredHeight: null, 16 | resizeDragger: null, 17 | resizeStartWidth: null, 18 | resizeStartHeight: null, 19 | 20 | init: function(container, settings) { 21 | // Param mapping 22 | if (typeof settings === 'undefined' && $.isPlainObject(container)) { 23 | // (settings) 24 | settings = container; 25 | container = null; 26 | } 27 | 28 | this.setSettings(settings, Garnish.Modal.defaults); 29 | 30 | // Create the shade 31 | this.$shade = $(''); 32 | 33 | // If the container is already set, drop the shade below it. 34 | if (container) { 35 | this.$shade.insertBefore(container); 36 | } 37 | else { 38 | this.$shade.appendTo(Garnish.$bod); 39 | } 40 | 41 | if (container) { 42 | this.setContainer(container); 43 | 44 | if (this.settings.autoShow) { 45 | this.show(); 46 | } 47 | } 48 | 49 | Garnish.Modal.instances.push(this); 50 | }, 51 | 52 | setContainer: function(container) { 53 | this.$container = $(container); 54 | 55 | // Is this already a modal? 56 | if (this.$container.data('modal')) { 57 | Garnish.log('Double-instantiating a modal on an element'); 58 | this.$container.data('modal').destroy(); 59 | } 60 | 61 | this.$container.data('modal', this); 62 | 63 | if (this.settings.draggable) { 64 | this.dragger = new Garnish.DragMove(this.$container, { 65 | handle: (this.settings.dragHandleSelector ? this.$container.find(this.settings.dragHandleSelector) : this.$container) 66 | }); 67 | } 68 | 69 | if (this.settings.resizable) { 70 | var $resizeDragHandle = $('').appendTo(this.$container); 71 | 72 | this.resizeDragger = new Garnish.BaseDrag($resizeDragHandle, { 73 | onDragStart: this._handleResizeStart.bind(this), 74 | onDrag: this._handleResize.bind(this) 75 | }); 76 | } 77 | 78 | this.addListener(this.$container, 'click', function(ev) { 79 | ev.stopPropagation(); 80 | }); 81 | 82 | // Show it if we're late to the party 83 | if (this.visible) { 84 | this.show(); 85 | } 86 | }, 87 | 88 | show: function() { 89 | // Close other modals as needed 90 | if (this.settings.closeOtherModals && Garnish.Modal.visibleModal && Garnish.Modal.visibleModal !== this) { 91 | Garnish.Modal.visibleModal.hide(); 92 | } 93 | 94 | if (this.$container) { 95 | // Move it to the end of so it gets the highest sub-z-index 96 | this.$shade.appendTo(Garnish.$bod); 97 | this.$container.appendTo(Garnish.$bod); 98 | 99 | this.$container.show(); 100 | this.updateSizeAndPosition(); 101 | 102 | this.$shade.velocity('fadeIn', { 103 | duration: 50, 104 | complete: function() { 105 | this.$container.velocity('fadeIn', { 106 | complete: function() { 107 | this.updateSizeAndPosition(); 108 | this.onFadeIn(); 109 | }.bind(this) 110 | }); 111 | }.bind(this) 112 | }); 113 | 114 | if (this.settings.hideOnShadeClick) { 115 | this.addListener(this.$shade, 'click', 'hide'); 116 | } 117 | 118 | this.addListener(Garnish.$win, 'resize', '_handleWindowResize'); 119 | } 120 | 121 | this.enable(); 122 | 123 | if (!this.visible) { 124 | this.visible = true; 125 | Garnish.Modal.visibleModal = this; 126 | 127 | Garnish.shortcutManager.addLayer(); 128 | 129 | if (this.settings.hideOnEsc) { 130 | Garnish.shortcutManager.registerShortcut(Garnish.ESC_KEY, this.hide.bind(this)); 131 | } 132 | 133 | this.trigger('show'); 134 | this.settings.onShow(); 135 | } 136 | }, 137 | 138 | quickShow: function() { 139 | this.show(); 140 | 141 | if (this.$container) { 142 | this.$container.velocity('stop'); 143 | this.$container.show().css('opacity', 1); 144 | 145 | this.$shade.velocity('stop'); 146 | this.$shade.show().css('opacity', 1); 147 | } 148 | }, 149 | 150 | hide: function(ev) { 151 | if (!this.visible) { 152 | return; 153 | } 154 | 155 | this.disable(); 156 | 157 | if (ev) { 158 | ev.stopPropagation(); 159 | } 160 | 161 | if (this.$container) { 162 | this.$container.velocity('fadeOut', {duration: Garnish.FX_DURATION}); 163 | this.$shade.velocity('fadeOut', { 164 | duration: Garnish.FX_DURATION, 165 | complete: this.onFadeOut.bind(this) 166 | }); 167 | 168 | if (this.settings.hideOnShadeClick) { 169 | this.removeListener(this.$shade, 'click'); 170 | } 171 | 172 | this.removeListener(Garnish.$win, 'resize'); 173 | } 174 | 175 | this.visible = false; 176 | Garnish.Modal.visibleModal = null; 177 | Garnish.shortcutManager.removeLayer(); 178 | this.trigger('hide'); 179 | this.settings.onHide(); 180 | }, 181 | 182 | quickHide: function() { 183 | this.hide(); 184 | 185 | if (this.$container) { 186 | this.$container.velocity('stop'); 187 | this.$container.css('opacity', 0).hide(); 188 | 189 | this.$shade.velocity('stop'); 190 | this.$shade.css('opacity', 0).hide(); 191 | } 192 | }, 193 | 194 | updateSizeAndPosition: function() { 195 | if (!this.$container) { 196 | return; 197 | } 198 | 199 | this.$container.css({ 200 | 'width': (this.desiredWidth ? Math.max(this.desiredWidth, 200) : ''), 201 | 'height': (this.desiredHeight ? Math.max(this.desiredHeight, 200) : ''), 202 | 'min-width': '', 203 | 'min-height': '' 204 | }); 205 | 206 | // Set the width first so that the height can adjust for the width 207 | this.updateSizeAndPosition._windowWidth = Garnish.$win.width(); 208 | this.updateSizeAndPosition._width = Math.min(this.getWidth(), this.updateSizeAndPosition._windowWidth - this.settings.minGutter * 2); 209 | 210 | this.$container.css({ 211 | 'width': this.updateSizeAndPosition._width, 212 | 'min-width': this.updateSizeAndPosition._width, 213 | 'left': Math.round((this.updateSizeAndPosition._windowWidth - this.updateSizeAndPosition._width) / 2) 214 | }); 215 | 216 | // Now set the height 217 | this.updateSizeAndPosition._windowHeight = Garnish.$win.height(); 218 | this.updateSizeAndPosition._height = Math.min(this.getHeight(), this.updateSizeAndPosition._windowHeight - this.settings.minGutter * 2); 219 | 220 | this.$container.css({ 221 | 'height': this.updateSizeAndPosition._height, 222 | 'min-height': this.updateSizeAndPosition._height, 223 | 'top': Math.round((this.updateSizeAndPosition._windowHeight - this.updateSizeAndPosition._height) / 2) 224 | }); 225 | 226 | this.trigger('updateSizeAndPosition'); 227 | }, 228 | 229 | onFadeIn: function() { 230 | this.trigger('fadeIn'); 231 | this.settings.onFadeIn(); 232 | }, 233 | 234 | onFadeOut: function() { 235 | this.trigger('fadeOut'); 236 | this.settings.onFadeOut(); 237 | }, 238 | 239 | getHeight: function() { 240 | if (!this.$container) { 241 | throw 'Attempted to get the height of a modal whose container has not been set.'; 242 | } 243 | 244 | if (!this.visible) { 245 | this.$container.show(); 246 | } 247 | 248 | this.getHeight._height = this.$container.outerHeight(); 249 | 250 | if (!this.visible) { 251 | this.$container.hide(); 252 | } 253 | 254 | return this.getHeight._height; 255 | }, 256 | 257 | getWidth: function() { 258 | if (!this.$container) { 259 | throw 'Attempted to get the width of a modal whose container has not been set.'; 260 | } 261 | 262 | if (!this.visible) { 263 | this.$container.show(); 264 | } 265 | 266 | // Chrome might be 1px shy here for some reason 267 | this.getWidth._width = this.$container.outerWidth() + 1; 268 | 269 | if (!this.visible) { 270 | this.$container.hide(); 271 | } 272 | 273 | return this.getWidth._width; 274 | }, 275 | 276 | _handleWindowResize: function(ev) { 277 | // ignore propagated resize events 278 | if (ev.target === window) { 279 | this.updateSizeAndPosition(); 280 | } 281 | }, 282 | 283 | _handleResizeStart: function() { 284 | this.resizeStartWidth = this.getWidth(); 285 | this.resizeStartHeight = this.getHeight(); 286 | }, 287 | 288 | _handleResize: function() { 289 | if (Garnish.ltr) { 290 | this.desiredWidth = this.resizeStartWidth + (this.resizeDragger.mouseDistX * 2); 291 | } 292 | else { 293 | this.desiredWidth = this.resizeStartWidth - (this.resizeDragger.mouseDistX * 2); 294 | } 295 | 296 | this.desiredHeight = this.resizeStartHeight + (this.resizeDragger.mouseDistY * 2); 297 | 298 | this.updateSizeAndPosition(); 299 | }, 300 | 301 | /** 302 | * Destroy 303 | */ 304 | destroy: function() { 305 | if (this.$container) { 306 | this.$container.removeData('modal').remove(); 307 | } 308 | 309 | if (this.$shade) { 310 | this.$shade.remove(); 311 | } 312 | 313 | if (this.dragger) { 314 | this.dragger.destroy(); 315 | } 316 | 317 | if (this.resizeDragger) { 318 | this.resizeDragger.destroy(); 319 | } 320 | 321 | this.base(); 322 | } 323 | }, 324 | { 325 | relativeElemPadding: 8, 326 | defaults: { 327 | autoShow: true, 328 | draggable: false, 329 | dragHandleSelector: null, 330 | resizable: false, 331 | minGutter: 10, 332 | onShow: $.noop, 333 | onHide: $.noop, 334 | onFadeIn: $.noop, 335 | onFadeOut: $.noop, 336 | closeOtherModals: false, 337 | hideOnEsc: true, 338 | hideOnShadeClick: true, 339 | shadeClass: 'modal-shade' 340 | }, 341 | instances: [], 342 | visibleModal: null 343 | } 344 | ); 345 | -------------------------------------------------------------------------------- /src/NiceText.js: -------------------------------------------------------------------------------- 1 | /** global: Garnish */ 2 | /** 3 | * Nice Text 4 | */ 5 | Garnish.NiceText = Garnish.Base.extend( 6 | { 7 | $input: null, 8 | $hint: null, 9 | $stage: null, 10 | $charsLeft: null, 11 | autoHeight: null, 12 | maxLength: null, 13 | showCharsLeft: false, 14 | showingHint: false, 15 | val: null, 16 | inputBoxSizing: 'content-box', 17 | width: null, 18 | height: null, 19 | minHeight: null, 20 | initialized: false, 21 | 22 | init: function(input, settings) { 23 | this.$input = $(input); 24 | this.settings = $.extend({}, Garnish.NiceText.defaults, settings); 25 | 26 | if (this.isVisible()) { 27 | this.initialize(); 28 | } 29 | else { 30 | this.addListener(Garnish.$win, 'resize', 'initializeIfVisible'); 31 | } 32 | }, 33 | 34 | isVisible: function() { 35 | return (this.$input.height() > 0); 36 | }, 37 | 38 | initialize: function() { 39 | if (this.initialized) { 40 | return; 41 | } 42 | 43 | this.initialized = true; 44 | this.removeListener(Garnish.$win, 'resize'); 45 | 46 | this.maxLength = this.$input.attr('maxlength'); 47 | 48 | if (this.maxLength) { 49 | this.maxLength = parseInt(this.maxLength); 50 | } 51 | 52 | if (this.maxLength && (this.settings.showCharsLeft || Garnish.hasAttr(this.$input, 'data-show-chars-left'))) { 53 | this.showCharsLeft = true; 54 | 55 | // Remove the maxlength attribute 56 | this.$input.removeAttr('maxlength'); 57 | } 58 | 59 | // Is this already a transparent text input? 60 | if (this.$input.data('nicetext')) { 61 | Garnish.log('Double-instantiating a transparent text input on an element'); 62 | this.$input.data('nicetext').destroy(); 63 | } 64 | 65 | this.$input.data('nicetext', this); 66 | 67 | this.getVal(); 68 | 69 | this.autoHeight = (this.settings.autoHeight && this.$input.prop('nodeName') === 'TEXTAREA'); 70 | 71 | if (this.autoHeight) { 72 | this.minHeight = this.getHeightForValue(''); 73 | this.updateHeight(); 74 | 75 | // Update height when the window resizes 76 | this.width = this.$input.width(); 77 | this.addListener(Garnish.$win, 'resize', 'updateHeightIfWidthChanged'); 78 | } 79 | 80 | if (this.settings.hint) { 81 | this.$hintContainer = $('').insertBefore(this.$input); 82 | this.$hint = $('' + this.settings.hint + '').appendTo(this.$hintContainer); 83 | this.$hint.css({ 84 | top: (parseInt(this.$input.css('borderTopWidth')) + parseInt(this.$input.css('paddingTop'))), 85 | left: (parseInt(this.$input.css('borderLeftWidth')) + parseInt(this.$input.css('paddingLeft')) + 1) 86 | }); 87 | Garnish.copyTextStyles(this.$input, this.$hint); 88 | 89 | if (this.val) { 90 | this.$hint.hide(); 91 | } 92 | else { 93 | this.showingHint = true; 94 | } 95 | 96 | // Focus the input when clicking on the hint 97 | this.addListener(this.$hint, 'mousedown', function(ev) { 98 | ev.preventDefault(); 99 | this.$input.focus(); 100 | }); 101 | } 102 | 103 | if (this.showCharsLeft) { 104 | this.$charsLeft = $('').insertAfter(this.$input); 105 | this.updateCharsLeft(); 106 | } 107 | 108 | this.addListener(this.$input, 'textchange', 'onTextChange'); 109 | this.addListener(this.$input, 'keydown', 'onKeyDown'); 110 | }, 111 | 112 | initializeIfVisible: function() { 113 | if (this.isVisible()) { 114 | this.initialize(); 115 | } 116 | }, 117 | 118 | getVal: function() { 119 | this.val = this.$input.val(); 120 | return this.val; 121 | }, 122 | 123 | showHint: function() { 124 | this.$hint.velocity('fadeIn', { 125 | complete: Garnish.NiceText.hintFadeDuration 126 | }); 127 | 128 | this.showingHint = true; 129 | }, 130 | 131 | hideHint: function() { 132 | this.$hint.velocity('fadeOut', { 133 | complete: Garnish.NiceText.hintFadeDuration 134 | }); 135 | 136 | this.showingHint = false; 137 | }, 138 | 139 | onTextChange: function() { 140 | this.getVal(); 141 | 142 | if (this.$hint) { 143 | if (this.showingHint && this.val) { 144 | this.hideHint(); 145 | } 146 | else if (!this.showingHint && !this.val) { 147 | this.showHint(); 148 | } 149 | } 150 | 151 | if (this.autoHeight) { 152 | this.updateHeight(); 153 | } 154 | 155 | if (this.showCharsLeft) { 156 | this.updateCharsLeft(); 157 | } 158 | }, 159 | 160 | onKeyDown: function(ev) { 161 | // If Ctrl/Command + Return is pressed, submit the closest form 162 | if (ev.keyCode === Garnish.RETURN_KEY && Garnish.isCtrlKeyPressed(ev)) { 163 | ev.preventDefault(); 164 | this.$input.closest('form').submit(); 165 | } 166 | }, 167 | 168 | buildStage: function() { 169 | this.$stage = $('').appendTo(Garnish.$bod); 170 | 171 | // replicate the textarea's text styles 172 | this.$stage.css({ 173 | display: 'block', 174 | position: 'absolute', 175 | top: -9999, 176 | left: -9999 177 | }); 178 | 179 | this.inputBoxSizing = this.$input.css('box-sizing'); 180 | 181 | if (this.inputBoxSizing === 'border-box') { 182 | this.$stage.css({ 183 | 'border-top': this.$input.css('border-top'), 184 | 'border-right': this.$input.css('border-right'), 185 | 'border-bottom': this.$input.css('border-bottom'), 186 | 'border-left': this.$input.css('border-left'), 187 | 'padding-top': this.$input.css('padding-top'), 188 | 'padding-right': this.$input.css('padding-right'), 189 | 'padding-bottom': this.$input.css('padding-bottom'), 190 | 'padding-left': this.$input.css('padding-left'), 191 | '-webkit-box-sizing': this.inputBoxSizing, 192 | '-moz-box-sizing': this.inputBoxSizing, 193 | 'box-sizing': this.inputBoxSizing 194 | }); 195 | } 196 | 197 | Garnish.copyTextStyles(this.$input, this.$stage); 198 | }, 199 | 200 | getHeightForValue: function(val) { 201 | if (!this.$stage) { 202 | this.buildStage(); 203 | } 204 | 205 | if (this.inputBoxSizing === 'border-box') { 206 | this.$stage.css('width', this.$input.outerWidth()); 207 | } 208 | else { 209 | this.$stage.css('width', this.$input.width()); 210 | } 211 | 212 | if (!val) { 213 | val = ' '; 214 | for (var i = 1; i < this.$input.prop('rows'); i++) { 215 | val += ' '; 216 | } 217 | } 218 | else { 219 | // Ampersand entities 220 | val = val.replace(/&/g, '&'); 221 | 222 | // < and > 223 | val = val.replace(//g, '>'); 225 | 226 | // Multiple spaces 227 | val = val.replace(/ {2,}/g, function(spaces) { 228 | // TODO: replace with String.repeat() when more broadly available? 229 | var replace = ''; 230 | for (var i = 0; i < spaces.length - 1; i++) { 231 | replace += ' '; 232 | } 233 | return replace + ' '; 234 | }); 235 | 236 | // Line breaks 237 | val = val.replace(/[\n\r]$/g, ' '); 238 | val = val.replace(/[\n\r]/g, ''); 239 | } 240 | 241 | this.$stage.html(val); 242 | 243 | if (this.inputBoxSizing === 'border-box') { 244 | this.getHeightForValue._height = this.$stage.outerHeight(); 245 | } 246 | else { 247 | this.getHeightForValue._height = this.$stage.height(); 248 | } 249 | 250 | if (this.minHeight && this.getHeightForValue._height < this.minHeight) { 251 | this.getHeightForValue._height = this.minHeight; 252 | } 253 | 254 | return this.getHeightForValue._height; 255 | }, 256 | 257 | updateHeight: function() { 258 | // has the height changed? 259 | if (this.height !== (this.height = this.getHeightForValue(this.val))) { 260 | this.$input.css('min-height', this.height); 261 | 262 | if (this.initialized) { 263 | this.onHeightChange(); 264 | } 265 | } 266 | }, 267 | 268 | updateHeightIfWidthChanged: function() { 269 | if (this.isVisible() && this.width !== (this.width = this.$input.width()) && this.width) { 270 | this.updateHeight(); 271 | } 272 | }, 273 | 274 | onHeightChange: function() { 275 | this.settings.onHeightChange(); 276 | }, 277 | 278 | updateCharsLeft: function() { 279 | this.updateCharsLeft._charsLeft = this.maxLength - this.val.length; 280 | this.$charsLeft.html(Garnish.NiceText.charsLeftHtml(this.updateCharsLeft._charsLeft)); 281 | 282 | if (this.updateCharsLeft._charsLeft >= 0) { 283 | this.$charsLeft.removeClass(this.settings.negativeCharsLeftClass); 284 | } 285 | else { 286 | this.$charsLeft.addClass(this.settings.negativeCharsLeftClass); 287 | } 288 | }, 289 | 290 | /** 291 | * Destroy 292 | */ 293 | destroy: function() { 294 | this.$input.removeData('nicetext'); 295 | 296 | if (this.$hint) { 297 | this.$hint.remove(); 298 | } 299 | 300 | if (this.$stage) { 301 | this.$stage.remove(); 302 | } 303 | 304 | this.base(); 305 | } 306 | }, 307 | { 308 | interval: 100, 309 | hintFadeDuration: 50, 310 | charsLeftHtml: function(charsLeft) { 311 | return charsLeft; 312 | }, 313 | defaults: { 314 | autoHeight: true, 315 | showCharsLeft: false, 316 | charsLeftClass: 'chars-left', 317 | negativeCharsLeftClass: 'negative-chars-left', 318 | onHeightChange: $.noop 319 | } 320 | } 321 | ); 322 | -------------------------------------------------------------------------------- /src/SelectMenu.js: -------------------------------------------------------------------------------- 1 | /** global: Garnish */ 2 | /** 3 | * Select Menu 4 | */ 5 | Garnish.SelectMenu = Garnish.CustomSelect.extend( 6 | { 7 | /** 8 | * Constructor 9 | */ 10 | init: function(btn, options, settings, callback) { 11 | // argument mapping 12 | if (typeof settings === 'function') { 13 | // (btn, options, callback) 14 | callback = settings; 15 | settings = {}; 16 | } 17 | 18 | settings = $.extend({}, Garnish.SelectMenu.defaults, settings); 19 | 20 | this.base(btn, options, settings, callback); 21 | 22 | this.selected = -1; 23 | }, 24 | 25 | /** 26 | * Build 27 | */ 28 | build: function() { 29 | this.base(); 30 | 31 | if (this.selected !== -1) { 32 | this._addSelectedOptionClass(this.selected); 33 | } 34 | }, 35 | 36 | /** 37 | * Select 38 | */ 39 | select: function(option) { 40 | // ignore if it's already selected 41 | if (option === this.selected) { 42 | return; 43 | } 44 | 45 | if (this.dom.ul) { 46 | if (this.selected !== -1) { 47 | this.dom.options[this.selected].className = ''; 48 | } 49 | 50 | this._addSelectedOptionClass(option); 51 | } 52 | 53 | this.selected = option; 54 | 55 | // set the button text to the selected option 56 | this.setBtnText($(this.options[option].label).text()); 57 | 58 | this.base(option); 59 | }, 60 | 61 | /** 62 | * Add Selected Option Class 63 | */ 64 | _addSelectedOptionClass: function(option) { 65 | this.dom.options[option].className = 'sel'; 66 | }, 67 | 68 | /** 69 | * Set Button Text 70 | */ 71 | setBtnText: function(text) { 72 | this.dom.$btnLabel.text(text); 73 | } 74 | 75 | }, 76 | { 77 | defaults: { 78 | ulClass: 'menu select' 79 | } 80 | } 81 | ); 82 | -------------------------------------------------------------------------------- /src/ShortcutManager.js: -------------------------------------------------------------------------------- 1 | /** global: Garnish */ 2 | /** 3 | * Keyboard shortcut manager class 4 | * 5 | * This can be used to map keyboard events to the current UI "layer" (whether that's the base document, 6 | * a modal, an HUD, or a menu). 7 | */ 8 | Garnish.ShortcutManager = Garnish.Base.extend( 9 | { 10 | shortcuts: null, 11 | layer: 0, 12 | 13 | init: function() { 14 | this.shortcuts = [[]]; 15 | this.addListener(Garnish.$bod, 'keydown', 'triggerShortcut'); 16 | }, 17 | 18 | addLayer: function() { 19 | this.layer++; 20 | this.shortcuts.push([]); 21 | return this; 22 | }, 23 | 24 | removeLayer: function() { 25 | if (this.layer === 0) { 26 | throw 'Can’t remove the base layer.'; 27 | } 28 | this.layer--; 29 | this.shortcuts.pop(); 30 | return this; 31 | }, 32 | 33 | registerShortcut: function(shortcut, callback, layer) { 34 | shortcut = this._normalizeShortcut(shortcut); 35 | if (typeof layer === 'undefined') { 36 | layer = this.layer; 37 | } 38 | this.shortcuts[layer].push({ 39 | key: JSON.stringify(shortcut), 40 | shortcut: shortcut, 41 | callback: callback, 42 | }); 43 | return this; 44 | }, 45 | 46 | unregisterShortcut: function(shortcut, layer) { 47 | shortcut = this._normalizeShortcut(shortcut); 48 | var key = JSON.stringify(shortcut); 49 | if (typeof layer === 'undefined') { 50 | layer = this.layer; 51 | } 52 | for (var i = 0; i < this.shortcuts[layer].length; i++) { 53 | if (this.shortcuts[layer][i].key === key) { 54 | this.shortcuts[layer].splice(i, 1); 55 | break; 56 | } 57 | } 58 | return this; 59 | }, 60 | 61 | _normalizeShortcut: function(shortcut) { 62 | if (typeof shortcut === 'number') { 63 | shortcut = {keyCode: shortcut}; 64 | } 65 | 66 | if (typeof shortcut.keyCode !== 'number') { 67 | throw 'Invalid shortcut'; 68 | } 69 | 70 | return { 71 | keyCode: shortcut.keyCode, 72 | ctrl: !!shortcut.ctrl, 73 | shift: !!shortcut.shift, 74 | alt: !!shortcut.alt, 75 | }; 76 | }, 77 | 78 | triggerShortcut: function(ev) { 79 | var shortcut; 80 | for (var i = 0; i < this.shortcuts[this.layer].length; i++) { 81 | shortcut = this.shortcuts[this.layer][i].shortcut; 82 | if ( 83 | shortcut.keyCode === ev.keyCode && 84 | shortcut.ctrl === Garnish.isCtrlKeyPressed(ev) && 85 | shortcut.shift === ev.shiftKey && 86 | shortcut.alt === ev.altKey 87 | ) { 88 | ev.preventDefault(); 89 | this.shortcuts[this.layer][i].callback(ev); 90 | break; 91 | } 92 | } 93 | }, 94 | } 95 | ); 96 | 97 | Garnish.shortcutManager = new Garnish.ShortcutManager(); 98 | -------------------------------------------------------------------------------- /test/CheckboxSelectTest.js: -------------------------------------------------------------------------------- 1 | describe("Garnish.CheckboxSelect tests", function() { 2 | 3 | var $container = $(''); 4 | $divAll = $('').appendTo($container); 5 | $all = $('').appendTo($divAll); 6 | $divOption1 = $('').appendTo($container); 7 | $option1 = $('').appendTo($divOption1); 8 | $divOption2 = $('').appendTo($container); 9 | $option2 = $('').appendTo($divOption2); 10 | 11 | var checkboxSelect = new Garnish.CheckboxSelect($container); 12 | 13 | it("$all should be defined", function() { 14 | expect(checkboxSelect.$all.get(0)).toBeDefined(); 15 | }); 16 | 17 | it("$options length should be greater than 0", function() { 18 | expect(checkboxSelect.$options.length).toBeGreaterThan(0); 19 | }); 20 | 21 | it("all options should be checked", function() { 22 | checkboxSelect.$all.prop('checked', true); 23 | checkboxSelect.$all.trigger('change'); 24 | 25 | var $option = $(checkboxSelect.$options[0]); 26 | 27 | expect($option.prop('checked')).toBe(true); 28 | }); 29 | 30 | it("Instantiating the checkbox select a second time should destroy the first instance and create a new one", function() { 31 | 32 | var checkboxSelect2 = new Garnish.CheckboxSelect($container); 33 | 34 | expect(checkboxSelect._namespace).not.toEqual(checkboxSelect2._namespace); 35 | }); 36 | 37 | }); -------------------------------------------------------------------------------- /test/GarnishTest.js: -------------------------------------------------------------------------------- 1 | describe("Garnish tests", function() { 2 | 3 | it("Checks whether a variable is an array.", function() { 4 | 5 | var mockArray = ['row 1', 'row 2']; 6 | 7 | expect(Garnish.isArray(mockArray)).toBe(true); 8 | }); 9 | 10 | it("Checks whether a variable is a string.", function() { 11 | 12 | var mockString = "Dummy string"; 13 | 14 | expect(Garnish.isString(mockString)).toBe(true); 15 | }); 16 | 17 | it("Checks whether an element has an attribute.", function() { 18 | 19 | var $element = $(''); 20 | 21 | expect(Garnish.hasAttr($element, 'class')).toBe(true); 22 | }); 23 | 24 | }); -------------------------------------------------------------------------------- /test/HUDTest.js: -------------------------------------------------------------------------------- 1 | describe("Garnish.HUD tests", function() { 2 | 3 | var $trigger = $('Trigger').appendTo(Garnish.$bod); 4 | var bodyContents = 'test'; 5 | 6 | var hud = new Garnish.HUD($trigger, bodyContents); 7 | 8 | it("Should instantiate the HUD.", function() { 9 | 10 | hudInstantiated = false; 11 | 12 | if(hud) 13 | { 14 | hudInstantiated = true; 15 | } 16 | 17 | expect(hudInstantiated).toBe(true); 18 | }); 19 | 20 | }); -------------------------------------------------------------------------------- /test/MenuTest.js: -------------------------------------------------------------------------------- 1 | describe("Garnish.Menu tests", function() { 2 | 3 | var $menu = $('').appendTo(Garnish.$bod), 4 | $ul = $('').appendTo($menu); 5 | 6 | var $anchor = $('').appendTo(Garnish.$bod); 7 | 8 | var menu = new Garnish.Menu($menu, { 9 | anchor: $anchor, 10 | }); 11 | 12 | it("Should instantiate the Menu.", function() { 13 | expect(menu.menuId).toEqual('menu' + menu._namespace); 14 | }); 15 | 16 | it("Should show the Menu.", function() { 17 | menu.show(); 18 | 19 | expect(menu.$container.css('opacity')).toEqual('1'); 20 | expect(menu.$container.css('display')).toEqual('block'); 21 | expect(menu.$menuList.attr('aria-hidden')).toEqual('false'); 22 | }); 23 | 24 | it("Should hide the Menu.", function() { 25 | menu.hide(); 26 | 27 | setTimeout(function() { 28 | expect(menu.$container.css('opacity')).toEqual('0'); 29 | expect(menu.$container.css('display')).toEqual('none'); 30 | }, Garnish.FX_DURATION); 31 | 32 | expect(menu.$menuList.attr('aria-hidden')).toEqual('true'); 33 | }); 34 | 35 | }); --------------------------------------------------------------------------------