├── .bowerrc ├── .editorconfig ├── .ember-cli ├── .gitignore ├── .jshintrc ├── .npmignore ├── .travis.yml ├── Brocfile.js ├── LICENSE.md ├── README.md ├── addon ├── .gitkeep ├── components │ └── popup-menu.js ├── computed │ ├── nearest-child.js │ ├── nearest-parent.js │ ├── stringify.js │ └── w.js ├── mixins │ └── scroll_sandbox.js └── system │ ├── constraint.js │ ├── flow.js │ ├── orientation.js │ ├── rectangle.js │ └── target.js ├── app ├── .gitkeep ├── components │ └── popup-menu.js ├── initializers │ └── popup-menu.js ├── popup-menu │ ├── animators │ │ ├── bounce.js │ │ └── scale.js │ └── flows │ │ ├── around.js │ │ ├── dropdown.js │ │ ├── flip.js │ │ └── popup.js └── templates │ └── components │ └── popup-menu.hbs ├── blueprints └── ember-popup-menu │ └── index.js ├── bower.json ├── config ├── ember-try.js └── environment.js ├── index.js ├── package.json ├── testem.json ├── tests ├── .jshintrc ├── acceptance │ └── events-test.js ├── dummy │ ├── .jshintrc │ ├── app │ │ ├── app.js │ │ ├── components │ │ │ └── .gitkeep │ │ ├── controllers │ │ │ └── .gitkeep │ │ ├── helpers │ │ │ └── .gitkeep │ │ ├── index.html │ │ ├── models │ │ │ └── .gitkeep │ │ ├── router.js │ │ ├── routes │ │ │ └── .gitkeep │ │ ├── styles │ │ │ ├── .gitkeep │ │ │ └── app.css │ │ ├── templates │ │ │ ├── .gitkeep │ │ │ ├── application.hbs │ │ │ └── components │ │ │ │ └── .gitkeep │ │ └── views │ │ │ └── .gitkeep │ ├── config │ │ └── environment.js │ └── public │ │ ├── .gitkeep │ │ ├── crossdomain.xml │ │ └── robots.txt ├── helpers │ ├── blur.js │ ├── focus.js │ ├── mouse-down.js │ ├── mouse-enter.js │ ├── mouse-leave.js │ ├── mouse-out.js │ ├── mouse-up.js │ ├── resolver.js │ ├── simple-click.js │ └── start-app.js ├── index.html ├── test-helper.js └── unit │ ├── .gitkeep │ ├── components │ └── popup-menu-test.js │ └── system │ ├── constraint-test.js │ ├── rectangle-test.js │ └── target-test.js └── vendor ├── .gitkeep └── styles └── ember-popup-menu.css /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "bower_components", 3 | "analytics": false 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.js] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [*.hbs] 21 | indent_style = space 22 | indent_size = 2 23 | 24 | [*.css] 25 | indent_style = space 26 | indent_size = 2 27 | 28 | [*.html] 29 | indent_style = space 30 | indent_size = 2 31 | 32 | [*.{diff,md}] 33 | trim_trailing_whitespace = false 34 | -------------------------------------------------------------------------------- /.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Ember CLI sends analytics information by default. The data is completely 4 | anonymous, but there are times when you might want to disable this behavior. 5 | 6 | Setting `disableAnalytics` to true will prevent any data from being sent. 7 | */ 8 | "disableAnalytics": false 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | 7 | # dependencies 8 | /node_modules 9 | /bower_components 10 | 11 | # misc 12 | /.sass-cache 13 | /connect.lock 14 | /coverage/* 15 | /libpeerconnection.log 16 | npm-debug.log 17 | testem.log 18 | *~ -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": [ 3 | "document", 4 | "window", 5 | "-Promise" 6 | ], 7 | "browser": true, 8 | "boss": true, 9 | "curly": true, 10 | "debug": false, 11 | "devel": true, 12 | "eqeqeq": true, 13 | "evil": true, 14 | "forin": false, 15 | "immed": false, 16 | "laxbreak": false, 17 | "newcap": true, 18 | "noarg": true, 19 | "noempty": false, 20 | "nonew": false, 21 | "nomen": false, 22 | "onevar": false, 23 | "plusplus": false, 24 | "regexp": false, 25 | "undef": true, 26 | "sub": true, 27 | "strict": false, 28 | "white": false, 29 | "eqnull": true, 30 | "esnext": true, 31 | "unused": true 32 | } 33 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | bower_components/ 2 | tests/ 3 | public/ 4 | tmp/ 5 | dist/ 6 | 7 | .bowerrc 8 | .editorconfig 9 | .ember-cli 10 | .travis.yml 11 | .npmignore 12 | **/.gitkeep 13 | bower.json 14 | Brocfile.js 15 | testem.json 16 | *~ 17 | *.tgz 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: node_js 3 | 4 | sudo: false 5 | 6 | cache: 7 | directories: 8 | - node_modules 9 | 10 | before_install: 11 | - "npm config set spin false" 12 | - "npm install -g npm@^2" 13 | 14 | install: 15 | - npm install -g bower 16 | - npm install 17 | - bower install 18 | 19 | script: 20 | - npm test 21 | -------------------------------------------------------------------------------- /Brocfile.js: -------------------------------------------------------------------------------- 1 | /* global require, module */ 2 | 3 | var EmberAddon = require('ember-cli/lib/broccoli/ember-addon'); 4 | 5 | var app = new EmberAddon(); 6 | 7 | app.import('bower_components/dom-ruler/dist/dom-ruler.amd.js', { 8 | exports: { 9 | 'dom-ruler': ['default'] 10 | } 11 | }); 12 | 13 | module.exports = app.toTree(); 14 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ember {{popup-menu}} 2 | 3 | **NOTE: This is still pre-1.0 software and is subject to change.** 4 | 5 | This popup-menu provides a pluggable interface for dealing with popups around your site. It has an inteface for registering constraint behaviors and animations. 6 | 7 | For use of the popup-menu as a tooltip, the following handlebars will do the trick: 8 | 9 | ```handlebars 10 | 11 | {{#popup-menu for="help-me" on="hover"}} 12 | Hey there! 13 | {{/popup-menu}} 14 | ``` 15 | 16 | ## Installation 17 | 18 | * `npm install --save-dev ember-popup-menu` 19 | * `ember g ember-popup-menu` 20 | 21 | ## Flows 22 | 23 | Flows provide a mechanism to programatically define how popups interact with the page. 24 | 25 | The API for flows is designed to provide a clear interface for understanding how a popup will react when the page is scrolled or resized. 26 | 27 | For example, a popup that always opens to the right of the target would look like: 28 | 29 | ```javascript 30 | export default function () { 31 | return this.orientRight.andSnapTo(this.center); 32 | } 33 | ``` 34 | 35 | If you would like the popup to slide between the top and bottom edge, you can use the `andSlideBetween` method: 36 | 37 | ```javascript 38 | export default function () { 39 | return this.orientRight.andSlideBetween(this.topEdge, this.bottomEdge); 40 | } 41 | ``` 42 | 43 | If no flow satisfies the constraints of the page, then the last flow in the cascade will be picked. 44 | 45 | Orientation | Description 46 | -------------|---------------- 47 | orientAbove | Orients the popup menu above the target 48 | orientLeft | Orients the popup menu to the left of the target 49 | orientBelow | Orients the popup menu below the target 50 | orientRight | Orients the popup menu to the right of the target 51 | 52 | Behavior | Description 53 | -----------------|---------------- 54 | andSnapTo | Attempts to snap to the locations given (in order) 55 | andSlideBetween | Positions the menu in between the locations given (the gravity of the menu is dictated by the positions of the parameters. For example, if the function is called `andSlideBetween(this.top, this.bottom)`, gravity will be reversed. 56 | where | A generic constraint that can pass / fail a constraint depending on the results of the function. This is hot code, and should be very straightforward. 57 | 58 | The list of possible locations for a flow depend on whether the flow is vertical or horizontal. 59 | 60 | For vertical flows (`orientLeft`, `orientRight`), the possible names are: 61 | 62 | * topEdge 63 | * center 64 | * bottomEdge 65 | 66 | For horizontal flows (`orientTop`, `orientBottom`), the possible names are: 67 | 68 | * leftEdge 69 | * center 70 | * rightEdge 71 | 72 | ## Recipes 73 | 74 | Tooltips: 75 | ```javascript 76 | import PopupMenu from "ember-popup-menu/components/popup-menu"; 77 | 78 | var ToolTip = PopupMenu.extend({ 79 | classNames: ['tool-tip'], 80 | layoutName: 'components/popup-menu', 81 | on: ['hover', 'focus'], 82 | flow: 'popup' 83 | }); 84 | 85 | export default ToolTip; 86 | ``` 87 | 88 | ```handlebars 89 | 90 | {{#tool-tip for="help-me"}} 91 | Hey there! 92 | {{/tool-tip}} 93 | ``` 94 | 95 | Dropdown menu: 96 | ```javascript 97 | import PopupMenu from "ember-popup-menu/components/popup-menu"; 98 | 99 | var DropDown = PopupMenu.extend({ 100 | classNames: ['drop-down'], 101 | layoutName: 'components/popup-menu', 102 | on: ['hover', 'focus', 'hold'], 103 | flow: 'dropdown' 104 | }); 105 | 106 | export default DropDown; 107 | ``` 108 | 109 | ```handlebars 110 |
Me
111 | {{#drop-down for="current-user"}} 112 | 116 | {{/drop-down}} 117 | ``` 118 | 119 | ## Writing your own components using {{popup-menu}} 120 | 121 | The {{popup-menu}} component is designed to be used with other components. It provides a programatic API for adding customized targets, and a set of utilities that allow for an easier and more consistent development experience when authoring these addons. 122 | 123 | Let's go through the steps of authoring a component that uses a {{popup-menu}} by making a {{date-picker}} widget. Some of the implementation details will be ignored to make this tutorial clearer to follow. 124 | 125 | First, let's bootstrap the addon: 126 | 127 | ```bash 128 | $ ember addon my-date-picker 129 | ``` 130 | 131 | After this we'll add `ember-popup-menu` and `ember-moment` as a dependencies (*not* a development dependency): 132 | 133 | ```bash 134 | $ cd my-date-picker 135 | $ npm install --save ember-popup-menu 136 | $ ember g ember-popup-menu 137 | $ npm install --save ember-moment 138 | $ ember g ember-moment 139 | ``` 140 | 141 | Now, we're ready to start authoring the addon. Let's first start by creating the component javascript file. 142 | 143 | ```bash 144 | $ mkdir addon/components 145 | $ touch addon/components/date-picker.js 146 | ``` 147 | 148 | Using the editor of your choice, add the following bootstrap code to get started: 149 | 150 | ```javascript 151 | import Ember from "ember"; 152 | 153 | var DatePicker = Ember.Component.extend({ 154 | classNames: ['date-picker'] 155 | }); 156 | 157 | export default DatePicker; 158 | ``` 159 | 160 | Let's define our public API first. This is what you will use to interface with the component in handlebars: 161 | 162 | ```javascript 163 | import Ember from "ember"; 164 | 165 | var DatePicker = Ember.Component.extend({ 166 | value: null, 167 | icon: null 168 | }); 169 | 170 | export default DatePicker; 171 | ``` 172 | 173 | `value` is the date that is picked and `icon` is an icon used to display a calendar icon. 174 | 175 | We're going to make the date picker a combination of a text field and a configurable icon, so let's start hooking them up so the popup-menu knows what will trigger events: 176 | 177 | ```javascript 178 | import Ember from "ember"; 179 | import nearestChild from "ember-popup-menu/computed/nearest-child"; 180 | 181 | var get = Ember.get; 182 | 183 | var DatePicker = Ember.Component.extend({ 184 | classNames: ['date-picker'], 185 | 186 | value: null, 187 | icon: null, 188 | 189 | popup: nearestChild('popup-menu'), 190 | 191 | attachTargets: function () { 192 | var popup = get(this, 'popup'); 193 | var icon = get(this, 'icon'); 194 | 195 | popup.addTarget(icon, { 196 | on: "click" 197 | }); 198 | }.on('didInsertElement') 199 | }); 200 | 201 | export default DatePicker; 202 | ``` 203 | 204 | Let's walk through the code. 205 | 206 | First, we imported `nearestChild`. This is a computed property that returns the nearest child of a given type. We then use this property to get the popup-menu. 207 | 208 | Then we add the icon as a target for the popup menu that will toggle the menu when clicked. 209 | 210 | For the next step, let's start showing the popup live and doing some iterative development. To do this, we'll need to start fiddling with the app directory. 211 | 212 | Create the `components` and `templates/components` directories under `app` at the root of your addon's project. 213 | 214 | The first thing to do is expose the component as a public interface by making a file called `date-picker.js` under `components`: 215 | 216 | ```javascript 217 | import DatePicker from "my-date-picker/components/date-picker"; 218 | export default DatePicker; 219 | ``` 220 | 221 | This simply exposes the date picker as a component consumable by the host application. 222 | 223 | Next, let's add a handlebars template for the date picker, under `templates/components/date-picker.hbs`: 224 | 225 | ```handlebars 226 | {{input type=text value=displayValue}}Open 227 | {{#popup-menu flow="dropdown" will-change="month" month=month}} 228 |
229 | < 230 |
{{moment firstOfMonth "MMMM"}}
231 | > 232 |
233 | {{calendar-month month=firstOfMonth}} 234 | {{/popup-menu}} 235 | ``` 236 | 237 | With the template we created, we've solidified a few requirements for the component. Let's go back to `date-picker.js` in the addon directory and suss these out. 238 | 239 | First, let's automatically generate an ID for the icon. This way, the popup-menu has a unique identifier for triggering on. While we're at it, let's implement the details around `month`. 240 | 241 | ```javascript 242 | import Ember from "ember"; 243 | import nearestChild from "ember-popup-menu/computed/nearest-child"; 244 | 245 | var generateGuid = Ember.generateGuid; 246 | 247 | var get = Ember.get; 248 | 249 | var DatePicker = Ember.Component.extend({ 250 | classNames: ['date-picker'], 251 | 252 | value: null, 253 | icon: function () { 254 | return generateGuid(); 255 | }.property(), 256 | 257 | popup: nearestChild('popup-menu'), 258 | 259 | attachTargets: function () { 260 | var popup = get(this, 'popup'); 261 | var icon = get(this, 'icon'); 262 | 263 | popup.addTarget(icon, { 264 | on: "click" 265 | }); 266 | }.on('didInsertElement'), 267 | 268 | actions: { 269 | previousMonth: function () { 270 | var previousMonth = get(this, 'firstOfMonth').clone().subtract(1, 'month'); 271 | set(this, 'month', previousMonth.month()); 272 | set(this, 'year', previousMonth.year()); 273 | }, 274 | 275 | nextMonth: function () { 276 | var nextMonth = get(this, 'firstOfMonth').clone().add(1, 'month'); 277 | set(this, 'month', nextMonth.month()); 278 | set(this, 'year', nextMonth.year()); 279 | } 280 | }, 281 | 282 | month: null, 283 | year: null, 284 | 285 | firstOfMonth: function () { 286 | return moment({ year: get(this, 'year'), month: get(this, 'month') }); 287 | }.property('year', 'month') 288 | 289 | }); 290 | 291 | export default DatePicker; 292 | ``` 293 | 294 | As a default, let's make month be the current month *or* the month of the selected value: 295 | 296 | ```javascript 297 | import Ember from "ember"; 298 | import moment from 'moment'; 299 | import nearestChild from "ember-popup-menu/computed/nearest-child"; 300 | 301 | var generateGuid = Ember.generateGuid; 302 | 303 | var get = Ember.get; 304 | 305 | var reads = Ember.computed.reads; 306 | 307 | var DatePicker = Ember.Component.extend({ 308 | classNames: ['date-picker'], 309 | 310 | value: null, 311 | icon: function () { 312 | return generateGuid(); 313 | }.property(), 314 | 315 | popup: nearestChild('popup-menu'), 316 | 317 | attachTargets: function () { 318 | var popup = get(this, 'popup'); 319 | var icon = get(this, 'icon'); 320 | 321 | popup.addTarget(icon, { 322 | on: "click" 323 | }); 324 | }.on('didInsertElement'), 325 | 326 | actions: { 327 | previousMonth: function () { 328 | var previousMonth = get(this, 'firstOfMonth').clone().subtract(1, 'month'); 329 | set(this, 'month', previousMonth.month()); 330 | set(this, 'year', previousMonth.year()); 331 | }, 332 | 333 | nextMonth: function () { 334 | var nextMonth = get(this, 'firstOfMonth').clone().add(1, 'month'); 335 | set(this, 'month', nextMonth.month()); 336 | set(this, 'year', nextMonth.year()); 337 | } 338 | }, 339 | 340 | month: reads('currentMonth'), 341 | year: reads('currentYear'), 342 | 343 | firstOfMonth: function () { 344 | return moment({ year: get(this, 'year'), month: get(this, 'month') }); 345 | }.property('year', 'month'), 346 | 347 | currentMonth: function () { 348 | return get(this, 'value') ? 349 | get(this, 'value').getMonth() : 350 | new Date().getMonth(); 351 | }.property(), 352 | 353 | currentYear: function () { 354 | return get(this, 'value') ? 355 | get(this, 'value').getFullYear() : 356 | new Date().getFullYear(); 357 | }.property(), 358 | 359 | displayValue: function () { 360 | var value = get(this, 'value'); 361 | return value ? moment(value).format("MM/DD/YYYY") : null; 362 | }.property('value') 363 | }); 364 | 365 | export default DatePicker; 366 | ``` 367 | 368 | With this much, we should be able to rotate through a list of months in the calendar year. Let's test this by commenting out the `{{calendar-month}}` component: 369 | 370 | ```handlebars 371 | {{input type=text value=displayValue}}Open 372 | {{#popup-menu flow="dropdown" will-change="month" month=month}} 373 |
374 | < 375 |
{{moment firstOfMonth "MMMM"}}
376 | > 377 |
378 | {{!calendar-month month=firstOfMonth}} 379 | {{/popup-menu}} 380 | ``` 381 | 382 | Now on to the next step! Let's implement the calendar-month component. In `calendar-month.js` in your addon, let's add code to come up with the days of the week and weeks in the given month. 383 | 384 | ```javascript 385 | import Ember from "ember"; 386 | import moment from "moment"; 387 | 388 | var get = Ember.get; 389 | 390 | var CalendarMonth = Ember.Component.extend({ 391 | classNames: ['calendar-month'], 392 | tagName: "table", 393 | 394 | dayNames: function () { 395 | var firstWeek = get(this, 'weeks.firstObject'); 396 | return firstWeek.map(function (day) { 397 | return moment(day).format("ddd"); 398 | }); 399 | }.property('weeks'), 400 | 401 | weeks: function () { 402 | var month = get(this, 'month'); 403 | var day = month.clone().startOf('week'); 404 | var weeks = []; 405 | var week = []; 406 | for (var iDay = 0; iDay < 7; iDay++) { 407 | week.push(day.clone().toDate()); 408 | day.add(1, 'day'); 409 | } 410 | weeks.push(week); 411 | 412 | while (day.month() === month.month()) { 413 | week = []; 414 | for (iDay = 0; iDay < 7; iDay++) { 415 | week.push(day.clone().toDate()); 416 | day.add(1, 'day'); 417 | } 418 | weeks.push(week); 419 | } 420 | return weeks; 421 | }.property('month') 422 | }); 423 | 424 | export default CalendarMonth; 425 | ``` 426 | 427 | And now let's add the template for that. First, expose the component in the app: 428 | 429 | ```javascript 430 | import CalendarMonth from "my-date-picker/components/calendar-month"; 431 | export default CalendarMonth; 432 | ``` 433 | 434 | And then add the template for it: 435 | 436 | ```handlebars 437 | 438 | 439 | {{#each dayOfWeek in dayNames}} 440 | {{dayOfWeek}} 441 | {{/each}} 442 | 443 | 444 | 445 | {{#each week in weeks}} 446 | 447 | {{#each day in week}} 448 | {{calendar-day value=day month=month}} 449 | {{/each}} 450 | 451 | {{/each}} 452 | 453 | ``` 454 | 455 | Hmm. Looks like we have yet another component to write! Let's finish off with that one, and then pop the stack all the way back to finish off the component. 456 | 457 | ```javascript 458 | import Ember from "ember"; 459 | import moment from "moment"; 460 | import nearestParent from "ember-popup-menu/computed/nearest-parent"; 461 | 462 | var get = Ember.get; 463 | 464 | var reads = Ember.computed.reads; 465 | 466 | var CalendarDay = Ember.Component.extend({ 467 | classNames: ['calendar-day'], 468 | 469 | tagName: "td", 470 | classNameBindings: ['isSelected:selected', 'isToday', 'isDisabled:disabled'], 471 | 472 | datePicker: nearestParent('date-picker'), 473 | selection: reads('datePicker.value'), 474 | 475 | isToday: function () { 476 | return moment(get(this, 'value')).isSame(new Date(), 'day'); 477 | }.property('value'), 478 | 479 | isSelected: function () { 480 | return moment(get(this, 'value')).isSame(get(this, 'selection'), 'day'); 481 | }.property('value', 'selection'), 482 | 483 | isDisabled: function () { 484 | return !moment(get(this, 'value')).isSame(get(this, 'month'), 'month'); 485 | }.property('value', 'month'), 486 | 487 | click: function () { 488 | if (get(this, 'isDisabled')) { return; } 489 | get(this, 'datePicker').send('selectDate', get(this, 'value')); 490 | } 491 | }); 492 | 493 | export default CalendarDay; 494 | ``` 495 | 496 | ```handlebars 497 | {{moment value "D"}} 498 | ``` 499 | 500 | Now let's pop our stack and finish by writing a handler for `selectDate` in `date-picker.js`: 501 | 502 | ```javascript 503 | import Ember from "ember"; 504 | import moment from 'moment'; 505 | import nearestChild from "ember-popup-menu/computed/nearest-child"; 506 | 507 | var generateGuid = Ember.generateGuid; 508 | 509 | var get = Ember.get; 510 | var set = Ember.set; 511 | 512 | var reads = Ember.computed.reads; 513 | 514 | var DatePicker = Ember.Component.extend({ 515 | classNames: ['date-picker'], 516 | value: null, 517 | icon: function () { 518 | return generateGuid(); 519 | }.property(), 520 | 521 | popup: nearestChild('popup-menu'), 522 | 523 | attachTargets: function () { 524 | var popup = get(this, 'popup'); 525 | var icon = get(this, 'icon'); 526 | 527 | popup.addTarget(icon, { 528 | on: "click" 529 | }); 530 | }.on('didInsertElement'), 531 | 532 | actions: { 533 | previousMonth: function () { 534 | var previousMonth = get(this, 'firstOfMonth').clone().subtract(1, 'month'); 535 | set(this, 'month', previousMonth.month()); 536 | set(this, 'year', previousMonth.year()); 537 | }, 538 | 539 | nextMonth: function () { 540 | var nextMonth = get(this, 'firstOfMonth').clone().add(1, 'month'); 541 | set(this, 'month', nextMonth.month()); 542 | set(this, 'year', nextMonth.year()); 543 | }, 544 | 545 | selectDate: function (date) { 546 | set(this, 'value', date); 547 | get(this, 'popup').deactivate(); 548 | } 549 | }, 550 | 551 | month: reads('currentMonth'), 552 | year: reads('currentYear'), 553 | 554 | firstOfMonth: function () { 555 | return moment({ year: get(this, 'year'), month: get(this, 'month') }); 556 | }.property('year', 'month'), 557 | 558 | currentMonth: function () { 559 | return get(this, 'value') ? 560 | get(this, 'value').getMonth() : 561 | new Date().getMonth(); 562 | }.property(), 563 | 564 | currentYear: function () { 565 | return get(this, 'value') ? 566 | get(this, 'value').getFullYear() : 567 | new Date().getFullYear(); 568 | }.property(), 569 | 570 | displayValue: function () { 571 | var value = get(this, 'value'); 572 | return value ? moment(value).format("MM/DD/YYYY") : null; 573 | }.property('value') 574 | }); 575 | 576 | export default DatePicker; 577 | ``` 578 | 579 | When we deactivate the popup, we're telling it that all targets are not active anymore. That way, the popup hides. 580 | 581 | To polish it off, let's add styling. Create a file in addons called `styles/my-date-picker.css` and add the following CSS: 582 | 583 | ```css 584 | .date-picker .popup-menu { 585 | padding: 20px; 586 | } 587 | 588 | .date-picker header { 589 | height: 25px; 590 | position: relative; 591 | } 592 | .date-picker .next-month, 593 | .date-picker .previous-month { 594 | cursor: pointer; 595 | position: absolute; 596 | top: 0; 597 | } 598 | 599 | .date-picker .next-month { 600 | right: 0; 601 | } 602 | 603 | .date-picker .previous-month { 604 | left: 0; 605 | } 606 | 607 | .date-picker .month { 608 | text-align: center; 609 | } 610 | 611 | .calendar-month { 612 | border-collapse: collapse; 613 | border-spacing: 0; 614 | } 615 | 616 | .calendar-month th { 617 | font-family: sans-serif; 618 | text-transform: uppercase; 619 | font-size: 12px; 620 | height: 30px; 621 | border-bottom: 1px solid #999; 622 | border-top: 3px solid #FFF; 623 | margin-bottom: 5px; 624 | } 625 | 626 | .calendar-day { 627 | cursor: pointer; 628 | text-align: center; 629 | width: 20px; 630 | height: 20px; 631 | padding: 1px; 632 | } 633 | 634 | .calendar-day span { 635 | display: block; 636 | padding: 5px; 637 | } 638 | 639 | .calendar-day.disabled { 640 | color: #999; 641 | cursor: default; 642 | } 643 | 644 | .calendar-day.is-today span { 645 | border: 1px solid #666; 646 | } 647 | 648 | .calendar-day.selected span { 649 | border: 1px solid #FFF; 650 | } 651 | ``` 652 | 653 | If everything went well, you should have a date-picker that behaves like the one here: http://paddle8.github.io/ember-popup-menu/ 654 | 655 | 656 | ## Running 657 | 658 | * `ember server` 659 | * Visit your app at http://localhost:4200. 660 | 661 | ## Running Tests 662 | 663 | * `ember test` 664 | * `ember test --server` 665 | 666 | ## Building 667 | 668 | * `ember build` 669 | 670 | For more information on using ember-cli, visit [http://www.ember-cli.com/](http://www.ember-cli.com/). 671 | -------------------------------------------------------------------------------- /addon/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paddle8/ember-popup-menu/7e05039689ea3c87aaabe5fc3d11170c6977b286/addon/.gitkeep -------------------------------------------------------------------------------- /addon/components/popup-menu.js: -------------------------------------------------------------------------------- 1 | import Ember from "ember"; 2 | import Target from "../system/target"; 3 | import Rectangle from "../system/rectangle"; 4 | import w from "../computed/w"; 5 | 6 | const computed = Ember.computed; 7 | const on = Ember.on; 8 | const observer = Ember.observer; 9 | 10 | const bind = Ember.run.bind; 11 | const scheduleOnce = Ember.run.scheduleOnce; 12 | const next = Ember.run.next; 13 | const cancel = Ember.run.cancel; 14 | 15 | const get = Ember.get; 16 | const set = Ember.set; 17 | const fmt = Ember.String.fmt; 18 | 19 | const alias = Ember.computed.alias; 20 | const bool = Ember.computed.bool; 21 | const filterBy = Ember.computed.filterBy; 22 | 23 | const addObserver = Ember.addObserver; 24 | const removeObserver = Ember.removeObserver; 25 | 26 | const RSVP = Ember.RSVP; 27 | 28 | const isSimpleClick = Ember.ViewUtils.isSimpleClick; 29 | const $ = Ember.$; 30 | 31 | export default Ember.Component.extend({ 32 | 33 | isVisible: false, 34 | 35 | classNames: ['popup-menu'], 36 | 37 | classNameBindings: ['orientationClassName', 'pointerClassName'], 38 | 39 | orientationClassName: computed('orientation', function () { 40 | var orientation = get(this, 'orientation'); 41 | return orientation ? fmt('orient-%@', [orientation]) : null; 42 | }), 43 | 44 | pointerClassName: computed('pointer', function () { 45 | var pointer = get(this, 'pointer'); 46 | return pointer ? fmt('pointer-%@', [pointer]) : null; 47 | }), 48 | 49 | disabled: false, 50 | 51 | orientation: null, 52 | 53 | pointer: null, 54 | 55 | flow: 'around', 56 | 57 | /** 58 | The target element of the popup menu. 59 | Can be a view, id, or element. 60 | */ 61 | for: null, 62 | 63 | on: null, 64 | 65 | addTarget: function (target, options) { 66 | get(this, 'targets').pushObject(Target.create(options, { 67 | component: this, 68 | target: target 69 | })); 70 | }, 71 | 72 | targets: computed(function() { 73 | return Ember.A(); 74 | }), 75 | 76 | /** 77 | Property that notifies the popup menu to retile 78 | */ 79 | 'will-change': alias('willChange'), 80 | willChange: w(), 81 | 82 | willChangeDidChange: on('init', observer('willChange', function () { 83 | (get(this, '_oldWillChange') || Ember.A()).forEach(function (key) { 84 | removeObserver(this, key, this, 'retile'); 85 | }, this); 86 | 87 | get(this, 'willChange').forEach(function (key) { 88 | addObserver(this, key, this, 'retile'); 89 | }, this); 90 | 91 | set(this, '_oldWillChange', get(this, 'willChange')); 92 | this.retile(); 93 | })), 94 | 95 | // .............................................. 96 | // Event management 97 | // 98 | 99 | attachWindowEvents: on('didInsertElement', function () { 100 | this.retile(); 101 | 102 | var retile = this.__retile = bind(this, 'retile'); 103 | ['scroll', 'resize'].forEach(function (event) { 104 | $(window).on(event, retile); 105 | }); 106 | 107 | addObserver(this, 'isVisible', this, 'retile'); 108 | }), 109 | 110 | attachTargets: on('didInsertElement', function () { 111 | // Add implicit target 112 | if (get(this, 'for') && get(this, 'on')) { 113 | this.addTarget(get(this, 'for'), { 114 | on: get(this, 'on') 115 | }); 116 | } 117 | 118 | next(this, function () { 119 | get(this, 'targets').invoke('attach'); 120 | }); 121 | }), 122 | 123 | removeEvents: on('willDestroyElement', function () { 124 | get(this, 'targets').invoke('detach'); 125 | 126 | var retile = this.__retile; 127 | ['scroll', 'resize'].forEach(function (event) { 128 | $(window).off(event, retile); 129 | }); 130 | 131 | if (this.__documentClick) { 132 | $(document).off('mousedown', this.__documentClick); 133 | this.__documentClick = null; 134 | } 135 | 136 | removeObserver(this, 'isVisible', this, 'retile'); 137 | this.__retile = null; 138 | }), 139 | 140 | mouseEnter: function () { 141 | if (get(this, 'disabled')) { return; } 142 | set(this, 'hovered', true); 143 | }, 144 | 145 | mouseLeave: function () { 146 | if (get(this, 'disabled')) { return; } 147 | set(this, 'hovered', false); 148 | get(this, 'targets').setEach('hovered', false); 149 | }, 150 | 151 | mouseDown: function () { 152 | if (get(this, 'disabled')) { return; } 153 | set(this, 'active', true); 154 | }, 155 | 156 | mouseUp: function () { 157 | if (get(this, 'disabled')) { return; } 158 | set(this, 'active', false); 159 | }, 160 | 161 | documentClick: function (evt) { 162 | if (get(this, 'disabled')) { return; } 163 | 164 | set(this, 'active', false); 165 | var targets = get(this, 'targets'); 166 | var element = get(this, 'element'); 167 | var clicked = isSimpleClick(evt) && 168 | (evt.target === element || $.contains(element, evt.target)); 169 | var clickedAnyTarget = targets.any(function (target) { 170 | return target.isClicked(evt); 171 | }); 172 | 173 | if (!clicked && !clickedAnyTarget) { 174 | targets.setEach('active', false); 175 | } 176 | }, 177 | 178 | isActive: bool('activeTargets.length'), 179 | 180 | activeTargets: filterBy('targets', 'isActive', true), 181 | 182 | activeTarget: computed('activeTargets.[]', function () { 183 | if (get(this, 'isActive')) { 184 | return get(this, 'targets').findBy('anchor', true) || 185 | get(this, 'activeTargets.firstObject'); 186 | } 187 | return null; 188 | }), 189 | 190 | activate: function (target) { 191 | get(this, 'targets').findBy('target', target).set('isActive', true); 192 | }, 193 | 194 | deactivate: function (target) { 195 | if (target == null) { 196 | get(this, 'targets').setEach('isActive', false); 197 | } else { 198 | get(this, 'targets').findBy('target', target).set('isActive', false); 199 | } 200 | }, 201 | 202 | /** 203 | Before the menu is shown, setup click events 204 | to catch when the user clicks outside the 205 | menu. 206 | */ 207 | visibilityDidChange: on('init', observer('isActive', function () { 208 | var component = this; 209 | 210 | if (this._animation) { 211 | this._animation.then(function () { 212 | component.visibilityDidChange(); 213 | }); 214 | } 215 | 216 | scheduleOnce('afterRender', this, 'animateMenu'); 217 | })), 218 | 219 | animateMenu: function () { 220 | var component = this; 221 | var proxy = this.__documentClick = this.__documentClick || bind(this, 'documentClick'); 222 | var animation = get(this, 'animation'); 223 | 224 | var isActive = get(this, 'isActive'); 225 | var isInactive = !isActive; 226 | var isVisible = get(this, 'isVisible'); 227 | var isHidden = !isVisible; 228 | 229 | if (isActive && isHidden) { 230 | this._animation = this.show(animation).then(function () { 231 | $(document).on('mousedown', proxy); 232 | component._animation = null; 233 | }); 234 | 235 | // Remove click events immediately 236 | } else if (isInactive && isVisible) { 237 | $(document).off('mousedown', proxy); 238 | this._animation = this.hide(animation).then(function () { 239 | component._animation = null; 240 | }); 241 | } 242 | }, 243 | 244 | hide: function (animationName) { 245 | var deferred = RSVP.defer(); 246 | var component = this; 247 | var animation = this.container.lookup('popup-animation:' + animationName); 248 | this._hider = next(this, function () { 249 | if (this.isDestroyed) { return; } 250 | 251 | if (animation) { 252 | var promise = animation.out.call(this); 253 | promise.then(function () { 254 | set(component, 'isVisible', false); 255 | }); 256 | deferred.resolve(promise); 257 | } else { 258 | set(component, 'isVisible', false); 259 | deferred.resolve(); 260 | } 261 | }); 262 | return deferred.promise; 263 | }, 264 | 265 | show: function (animationName) { 266 | cancel(this._hider); 267 | 268 | var deferred = RSVP.defer(); 269 | var animation = this.container.lookup('popup-animation:' + animationName); 270 | set(this, 'isVisible', true); 271 | scheduleOnce('afterRender', this, function () { 272 | if (animation) { 273 | deferred.resolve(animation['in'].call(this)); 274 | } else { 275 | deferred.resolve(); 276 | } 277 | }); 278 | 279 | return deferred.promise; 280 | }, 281 | 282 | retile: function () { 283 | if (get(this, 'isVisible')) { 284 | scheduleOnce('afterRender', this, 'tile'); 285 | } 286 | }, 287 | 288 | tile: function () { 289 | var target = get(this, 'activeTarget'); 290 | // Don't tile if there's nothing to constrain the popup menu around 291 | if (!get(this, 'element') || !target) { 292 | return; 293 | } 294 | 295 | var $popup = this.$(); 296 | var $pointer = $popup.children('.popup-menu_pointer'); 297 | 298 | var boundingRect = Rectangle.ofElement(window); 299 | var popupRect = Rectangle.ofView(this, 'padding'); 300 | var targetRect = Rectangle.ofElement(target.element, 'padding'); 301 | var pointerRect = Rectangle.ofElement($pointer[0], 'borders'); 302 | 303 | if (boundingRect.intersects(targetRect)) { 304 | var flowName = get(this, 'flow'); 305 | var constraints = this.container.lookup('popup-constraint:' + flowName); 306 | Ember.assert(fmt( 307 | ("The flow named '%@1' was not registered with the {{popup-menu}}.\n" + 308 | "Register your flow by creating a file at 'app/popup-menu/flows/%@1.js' with the following function body:\n\nexport default function %@1 () {\n return this.orientBelow().andSnapTo(this.center);\n});"), [flowName]), constraints); 309 | var solution; 310 | for (var i = 0, len = constraints.length; i < len; i++) { 311 | solution = constraints[i].solveFor(boundingRect, targetRect, popupRect, pointerRect); 312 | if (solution.valid) { break; } 313 | } 314 | 315 | this.setProperties({ 316 | orientation: solution.orientation, 317 | pointer: solution.pointer 318 | }); 319 | 320 | var offset = $popup.offsetParent().offset(); 321 | var top = popupRect.top - offset.top; 322 | var left = popupRect.left - offset.left; 323 | $popup.css({ 324 | top: top + 'px', 325 | left: left + 'px' 326 | }); 327 | $pointer.css({ 328 | top: pointerRect.top + 'px', 329 | left: pointerRect.left + 'px' 330 | }); 331 | } 332 | } 333 | 334 | }); 335 | -------------------------------------------------------------------------------- /addon/computed/nearest-child.js: -------------------------------------------------------------------------------- 1 | import Ember from "ember"; 2 | 3 | const computed = Ember.computed; 4 | const bind = Ember.run.bind; 5 | const get = Ember.get; 6 | 7 | function flatten(array) { 8 | return Ember.A(array).reduce(function (a, b) { 9 | return a.concat(b); 10 | }, Ember.A()); 11 | } 12 | 13 | function recursivelyFindByType(typeClass, children) { 14 | let view = children.find(function (view) { 15 | return typeClass.detectInstance(view); 16 | }); 17 | 18 | if (view) { 19 | return view; 20 | } 21 | 22 | let childrenOfChildren = flatten(children.getEach('childViews')); 23 | if (childrenOfChildren.length === 0) { 24 | return null; 25 | } 26 | return recursivelyFindByType(typeClass, childrenOfChildren); 27 | } 28 | 29 | export default function(type) { 30 | var tracking = Ember.Map.create(); 31 | var deleteItem; 32 | if (tracking.delete) { 33 | deleteItem = bind(tracking, 'delete'); 34 | } else { 35 | deleteItem = bind(tracking, 'remove'); 36 | } 37 | 38 | return computed('childViews.[]', function nearestChild(key) { 39 | var typeClass = this.container.lookupFactory('component:' + type) || 40 | this.container.lookupFactory('view:' + type); 41 | 42 | var children = Ember.A(get(this, 'childViews')); 43 | var appendedChildren = children.filterBy('_state', 'inDOM'); 44 | var detachedChildren = children.filter(function (child) { 45 | return ['inBuffer', 'hasElement', 'preRender'].indexOf(child._state) !== -1; 46 | }); 47 | 48 | appendedChildren.forEach(function (child) { 49 | deleteItem(child); 50 | }); 51 | 52 | var notifyChildrenChanged = bind(this, 'notifyPropertyChange', key); 53 | detachedChildren.forEach(function (child) { 54 | if (!tracking.has(child)) { 55 | child.one('didInsertElement', this, notifyChildrenChanged); 56 | tracking.set(child, true); 57 | } 58 | }); 59 | 60 | return recursivelyFindByType(typeClass, appendedChildren); 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /addon/computed/nearest-parent.js: -------------------------------------------------------------------------------- 1 | import Ember from "ember"; 2 | 3 | const computed = Ember.computed; 4 | 5 | export default function(type) { 6 | return computed(function nearestParent() { 7 | var typeClass = this.container.lookupFactory('component:' + type) || 8 | this.container.lookupFactory('view:' + type); 9 | return this.nearestOfType(typeClass); 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /addon/computed/stringify.js: -------------------------------------------------------------------------------- 1 | import Ember from "ember"; 2 | 3 | const computed = Ember.computed; 4 | const get = Ember.get; 5 | 6 | export default function(property) { 7 | return computed(property, function stringify() { 8 | return String(get(this, property)); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /addon/computed/w.js: -------------------------------------------------------------------------------- 1 | import Ember from "ember"; 2 | 3 | const computed = Ember.computed; 4 | const w = Ember.String.w; 5 | 6 | const toArray = function (value) { 7 | if (typeof value === "string") { 8 | value = w(value); 9 | } 10 | return value; 11 | }; 12 | 13 | export default function(defaultValue) { 14 | defaultValue = defaultValue || []; 15 | return computed(function w(key, value) { 16 | if (arguments.length > 1) { 17 | value = toArray(value); 18 | } 19 | return Ember.A(value || toArray(defaultValue)); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /addon/mixins/scroll_sandbox.js: -------------------------------------------------------------------------------- 1 | import Ember from "ember"; 2 | 3 | const debounce = Ember.run.debounce; 4 | const set = Ember.set; 5 | const get = Ember.get; 6 | const bind = Ember.run.bind; 7 | 8 | const on = Ember.on; 9 | 10 | // Normalize mouseWheel events 11 | function mouseWheel(evt) { 12 | let oevt = evt.originalEvent; 13 | let delta = 0; 14 | let deltaY = 0; 15 | let deltaX = 0; 16 | 17 | if (oevt.wheelDelta) { 18 | delta = oevt.wheelDelta / 120; 19 | } 20 | if (oevt.detail) { 21 | delta = oevt.detail / -3; 22 | } 23 | 24 | deltaY = delta; 25 | 26 | if (oevt.hasOwnProperty) { 27 | // Gecko 28 | if (oevt.hasOwnProperty('axis') && oevt.axis === oevt.HORIZONTAL_AXIS) { 29 | deltaY = 0; 30 | deltaX = -1 * delta; 31 | } 32 | 33 | // Webkit 34 | if (oevt.hasOwnProperty('wheelDeltaY')) { 35 | deltaY = oevt.wheelDeltaY / +120; 36 | } 37 | if (oevt.hasOwnProperty('wheelDeltaX')) { 38 | deltaX = oevt.wheelDeltaX / -120; 39 | } 40 | } 41 | 42 | evt.wheelDeltaX = deltaX; 43 | evt.wheelDeltaY = deltaY; 44 | 45 | return this.mouseWheel(evt); 46 | } 47 | 48 | /** 49 | Adding this mixin to a view will add scroll behavior that bounds 50 | the scrolling to the contents of the box. 51 | 52 | When the user has stopped scrolling and they are at an edge of the 53 | box, then it will relinquish control to the parent scroll container. 54 | 55 | This is useful when designing custom popup components that scroll 56 | that should behave like native controls. 57 | 58 | @class ScrollSandbox 59 | @extends Ember.Mixin 60 | */ 61 | export default Ember.Mixin.create({ 62 | 63 | setupScrollHandlers: on('didInsertElement', function () { 64 | this._mouseWheelHandler = bind(this, mouseWheel); 65 | this.$().on('mousewheel DOMMouseScroll', this._mouseWheelHandler); 66 | }), 67 | 68 | scrollingHasStopped: function () { 69 | set(this, 'isScrolling', false); 70 | }, 71 | 72 | /** @private 73 | Prevent scrolling the result list from scrolling 74 | the window. 75 | */ 76 | mouseWheel: function (evt) { 77 | const $element = this.$(); 78 | const scrollTop = $element.scrollTop(); 79 | const maximumScrollTop = $element.prop('scrollHeight') - 80 | $element.outerHeight(); 81 | var isAtScrollEdge; 82 | 83 | if (evt.wheelDeltaY > 0) { 84 | isAtScrollEdge = scrollTop === 0; 85 | } else if (evt.wheelDeltaY < 0) { 86 | isAtScrollEdge = scrollTop === maximumScrollTop; 87 | } 88 | 89 | if (get(this, 'isScrolling') && isAtScrollEdge) { 90 | evt.preventDefault(); 91 | evt.stopPropagation(); 92 | } else if (!isAtScrollEdge) { 93 | set(this, 'isScrolling', true); 94 | } 95 | debounce(this, this.scrollingHasStopped, 75); 96 | }, 97 | 98 | teardownScrollHandlers: on('willDestroyElement', function () { 99 | this.$().off('mousewheel DOMMouseScroll', this._mouseWheelHandler); 100 | }) 101 | }); 102 | -------------------------------------------------------------------------------- /addon/system/constraint.js: -------------------------------------------------------------------------------- 1 | import Ember from "ember"; 2 | 3 | const keys = Ember.keys; 4 | const compare = Ember.compare; 5 | const mixin = Ember.mixin; 6 | 7 | function orientAbove(target, popup, pointer) { 8 | popup.setY(target.top - pointer.height - popup.height); 9 | pointer.setY(popup.height); 10 | } 11 | 12 | function orientBelow(target, popup, pointer) { 13 | popup.setY(target.bottom + pointer.height); 14 | pointer.setY(pointer.height * -1); 15 | } 16 | 17 | function orientLeft(target, popup, pointer) { 18 | popup.setX(target.left - pointer.width - popup.width); 19 | pointer.setX(popup.width); 20 | } 21 | 22 | function orientRight(target, popup, pointer) { 23 | popup.setX(target.right + pointer.width); 24 | pointer.setX(pointer.width * -1); 25 | } 26 | 27 | function horizontallyCenter(target, popup, pointer) { 28 | popup.setX(target.left + target.width / 2 - popup.width / 2); 29 | pointer.setX(popup.width / 2 - pointer.width / 2); 30 | } 31 | 32 | function verticallyCenter(target, popup, pointer) { 33 | popup.setY(target.top + target.height / 2 - popup.height / 2); 34 | pointer.setY(popup.height / 2 - pointer.height / 2); 35 | } 36 | 37 | function snapLeft(target, popup, pointer) { 38 | const offsetLeft = Math.min(target.width / 2 - (pointer.width * 1.5), 0); 39 | popup.setX(target.left + offsetLeft); 40 | pointer.setX(pointer.width); 41 | } 42 | 43 | function snapRight(target, popup, pointer) { 44 | const offsetRight = Math.min(target.width / 2 - (pointer.width * 1.5), 0); 45 | popup.setX(target.right - offsetRight - popup.width); 46 | pointer.setX(popup.width - pointer.width * 2); 47 | } 48 | 49 | function snapAbove(target, popup, pointer) { 50 | const offsetTop = Math.min(target.height / 2 - (pointer.height * 1.5), 0); 51 | popup.setY(target.top + offsetTop); 52 | pointer.setY(pointer.height); 53 | } 54 | 55 | function snapBelow(target, popup, pointer) { 56 | const offsetBottom = Math.min(target.height / 2 - (pointer.height * 1.5), 0); 57 | popup.setY(target.bottom - offsetBottom - popup.height); 58 | pointer.setY(popup.height - pointer.height * 2); 59 | } 60 | 61 | function slideHorizontally(guidelines, boundary, target, popup, pointer) { 62 | var edges = { 63 | 'left-edge': Math.min(target.width / 2 - (pointer.width * 1.5), 0), 64 | 'center': (target.width / 2 - popup.width / 2), 65 | 'right-edge': target.width - popup.width 66 | }; 67 | var range = Ember.A(guidelines).map(function (guideline) { 68 | return edges[guideline] || [-1, -1]; 69 | }); 70 | 71 | var left = target.x + range[0]; 72 | var right = left + popup.width; 73 | 74 | range = range.sort(function (a, b) { 75 | return compare(a, b); 76 | }); 77 | var minX = target.x + range[0]; 78 | var maxX = target.x + range[1]; 79 | 80 | var padding = pointer.width; 81 | 82 | // Adjust the popup so it remains in view 83 | if (left < boundary.left + padding) { 84 | left = boundary.left + padding; 85 | } else if (right > boundary.right - padding) { 86 | left = boundary.right - popup.width - padding; 87 | } 88 | 89 | var valid = left >= minX && left <= maxX; 90 | left = Math.max(Math.min(left, maxX), minX); 91 | 92 | popup.setX(left); 93 | 94 | var dX = target.left - left; 95 | var oneThird = (edges['left-edge'] - edges['right-edge']) / 3; 96 | var pointerClassName; 97 | 98 | if (dX < oneThird) { 99 | pointer.setX(dX + Math.min(pointer.width, target.width / 2 - pointer.width * 1.5)); 100 | pointerClassName = 'left-edge'; 101 | } else if (dX < oneThird * 2) { 102 | pointer.setX(dX + target.width / 2 - pointer.width / 2); 103 | pointerClassName = 'center'; 104 | } else { 105 | pointer.setX(dX + target.width - pointer.width * 1.5); 106 | pointerClassName = 'right-edge'; 107 | } 108 | 109 | return { 110 | valid: valid, 111 | pointer: pointerClassName 112 | }; 113 | } 114 | 115 | function slideVertically(guidelines, boundary, target, popup, pointer) { 116 | var edges = { 117 | 'top-edge': Math.min(target.height / 2 - (pointer.height * 1.5), 0), 118 | 'center': (target.height / 2 - popup.height / 2), 119 | 'bottom-edge': target.height - popup.height 120 | }; 121 | var range = Ember.A(guidelines).map(function (guideline) { 122 | return edges[guideline]; 123 | }); 124 | 125 | var top = target.y + range[0]; 126 | var bottom = top + popup.height; 127 | 128 | range = range.sort(function (a, b) { 129 | return compare(a, b); 130 | }); 131 | var minY = target.y + range[0]; 132 | var maxY = target.y + range[1]; 133 | 134 | var padding = pointer.height; 135 | 136 | // Adjust the popup so it remains in view 137 | if (top < boundary.top + padding) { 138 | top = boundary.top + padding; 139 | } else if (bottom > boundary.bottom - padding) { 140 | top = boundary.bottom - popup.height - padding; 141 | } 142 | 143 | var valid = top >= minY && top <= maxY; 144 | top = Math.max(Math.min(top, maxY), minY + padding); 145 | 146 | popup.setY(top); 147 | 148 | var dY = target.top - top; 149 | var oneThird = (edges['top-edge'] - edges['bottom-edge']) / 3; 150 | var pointerClassName; 151 | 152 | if (dY < oneThird) { 153 | pointer.setY(dY + pointer.height + Math.min(target.height / 2 - (pointer.height * 1.5), 0)); 154 | pointerClassName = 'top-edge'; 155 | } else if (dY < oneThird * 2) { 156 | pointer.setY(dY + target.height / 2 - pointer.height / 2); 157 | pointerClassName = 'center'; 158 | } else { 159 | pointer.setY(dY - Math.min(target.height + (pointer.height * 1.5), 0)); 160 | pointerClassName = 'bottom-edge'; 161 | } 162 | 163 | return { 164 | valid: valid, 165 | pointer: pointerClassName 166 | }; 167 | } 168 | 169 | function Constraint(object) { 170 | keys(object).forEach(function (key) { 171 | this[key] = object[key]; 172 | }, this); 173 | } 174 | 175 | Constraint.prototype.solveFor = function (boundingRect, targetRect, popupRect, pointerRect) { 176 | var orientation = this.orientation; 177 | var result = { 178 | orientation: orientation, 179 | valid: true 180 | }; 181 | 182 | // Orient the pane 183 | switch (orientation) { 184 | case 'above': orientAbove(targetRect, popupRect, pointerRect); break; 185 | case 'below': orientBelow(targetRect, popupRect, pointerRect); break; 186 | case 'left': orientLeft(targetRect, popupRect, pointerRect); break; 187 | case 'right': orientRight(targetRect, popupRect, pointerRect); break; 188 | } 189 | 190 | // The pane should slide in the direction specified by the flow 191 | if (this.behavior === 'slide') { 192 | switch (orientation) { 193 | case 'above': 194 | case 'below': 195 | mixin(result, slideHorizontally(this.guideline, boundingRect, targetRect, popupRect, pointerRect)); 196 | break; 197 | case 'left': 198 | case 'right': 199 | mixin(result, slideVertically(this.guideline, boundingRect, targetRect, popupRect, pointerRect)); 200 | break; 201 | } 202 | 203 | } else if (this.behavior === 'snap') { 204 | result.pointer = this.guideline; 205 | switch (this.guideline) { 206 | case 'center': 207 | switch (this.orientation) { 208 | case 'above': 209 | case 'below': horizontallyCenter(targetRect, popupRect, pointerRect); break; 210 | case 'left': 211 | case 'right': verticallyCenter(targetRect, popupRect, pointerRect); break; 212 | } 213 | break; 214 | case 'top-edge': snapAbove(targetRect, popupRect, pointerRect); break; 215 | case 'bottom-edge': snapBelow(targetRect, popupRect, pointerRect); break; 216 | case 'right-edge': snapRight(targetRect, popupRect, pointerRect); break; 217 | case 'left-edge': snapLeft(targetRect, popupRect, pointerRect); break; 218 | } 219 | } 220 | 221 | result.valid = result.valid && boundingRect.contains(popupRect); 222 | return result; 223 | }; 224 | 225 | export default Constraint; 226 | -------------------------------------------------------------------------------- /addon/system/flow.js: -------------------------------------------------------------------------------- 1 | import Ember from "ember"; 2 | import Orientation from "./orientation"; 3 | 4 | const on = Ember.on; 5 | 6 | export default Ember.Object.extend({ 7 | setupOrienters: on('init', function() { 8 | this.orientAbove = Orientation.create({ orientation: 'above' }); 9 | this.orientBelow = Orientation.create({ orientation: 'below' }); 10 | this.orientRight = Orientation.create({ orientation: 'right' }); 11 | this.orientLeft = Orientation.create({ orientation: 'left' }); 12 | }), 13 | 14 | topEdge: 'top-edge', 15 | bottomEdge: 'bottom-edge', 16 | leftEdge: 'left-edge', 17 | rightEdge: 'right-edge', 18 | center: 'center' 19 | }); 20 | -------------------------------------------------------------------------------- /addon/system/orientation.js: -------------------------------------------------------------------------------- 1 | import Ember from "ember"; 2 | import Constraint from "./constraint"; 3 | 4 | const reads = Ember.computed.reads; 5 | const slice = Array.prototype.slice; 6 | const get = Ember.get; 7 | const set = Ember.set; 8 | const isArray = Ember.isArray; 9 | 10 | export default Ember.Object.extend({ 11 | 12 | init: function () { 13 | this._super(); 14 | 15 | this._constraints = Ember.A(); 16 | set(this, 'defaultConstraint', { 17 | orientation: get(this, 'orientation') 18 | }); 19 | }, 20 | 21 | orientation: null, 22 | 23 | defaultConstraint: null, 24 | 25 | constraints: reads('defaultConstraint'), 26 | 27 | andSnapTo: function (snapGuidelines) { 28 | var constraints = Ember.A(); 29 | var guideline; 30 | var orientation = get(this, 'orientation'); 31 | 32 | snapGuidelines = slice.call(arguments); 33 | 34 | for (var i = 0, len = snapGuidelines.length; i < len; i++) { 35 | guideline = snapGuidelines[i]; 36 | 37 | constraints.push( 38 | new Constraint({ 39 | orientation: orientation, 40 | behavior: 'snap', 41 | guideline: guideline 42 | }) 43 | ); 44 | } 45 | 46 | if (!isArray(get(this, 'constraints'))) { 47 | set(this, 'constraints', Ember.A()); 48 | } 49 | 50 | this._constraints.pushObjects(constraints); 51 | get(this, 'constraints').pushObjects(constraints); 52 | 53 | return this; 54 | }, 55 | 56 | andSlideBetween: function () { 57 | let constraint = new Constraint({ 58 | orientation: get(this, 'orientation'), 59 | behavior: 'slide', 60 | guideline: slice.call(arguments) 61 | }); 62 | 63 | if (!isArray(get(this, 'constraints'))) { 64 | set(this, 'constraints', Ember.A()); 65 | } 66 | 67 | this._constraints.pushObject(constraint); 68 | 69 | // Always unshift slide constraints, 70 | // since they should be handled first 71 | get(this, 'constraints').unshiftObjects(constraint); 72 | 73 | return this; 74 | }, 75 | 76 | where: function (condition) { 77 | this._constraints.forEach(function (constraint) { 78 | constraint.condition = condition; 79 | }); 80 | 81 | return this; 82 | }, 83 | 84 | then: function (guideline) { 85 | if (guideline !== this) { 86 | get(this, 'constraints').pushObjects(get(guideline, 'constraints')); 87 | } 88 | 89 | return this; 90 | } 91 | 92 | }); 93 | -------------------------------------------------------------------------------- /addon/system/rectangle.js: -------------------------------------------------------------------------------- 1 | import Ember from "ember"; 2 | import { getLayout } from "dom-ruler"; 3 | 4 | const get = Ember.get; 5 | const $ = Ember.$; 6 | 7 | var Rectangle = function (x, y, width, height) { 8 | this.x = this.left = x; 9 | this.y = this.top = y; 10 | this.right = x + width; 11 | this.bottom = y + height; 12 | this.width = width; 13 | this.height = height; 14 | this.area = width * height; 15 | }; 16 | 17 | Rectangle.prototype = { 18 | intersects: function (rect) { 19 | return Rectangle.intersection(this, rect).area > 0; 20 | }, 21 | 22 | contains: function (rect) { 23 | return Rectangle.intersection(this, rect).area === rect.area; 24 | }, 25 | 26 | translateX: function (dX) { 27 | this.x = this.left = this.x + dX; 28 | this.right += dX; 29 | }, 30 | 31 | translateY: function (dY) { 32 | this.y = this.top = this.y + dY; 33 | this.bottom += dY; 34 | }, 35 | 36 | translate: function (dX, dY) { 37 | this.translateX(dX); 38 | this.translateY(dY); 39 | }, 40 | 41 | setX: function (x) { 42 | this.translateX(x - this.x); 43 | }, 44 | 45 | setY: function (y) { 46 | this.translateY(y - this.y); 47 | } 48 | }; 49 | 50 | Rectangle.intersection = function (rectA, rectB) { 51 | // Find the edges 52 | var x = Math.max(rectA.x, rectB.x); 53 | var y = Math.max(rectA.y, rectB.y); 54 | var right = Math.min(rectA.right, rectB.right); 55 | var bottom = Math.min(rectA.bottom, rectB.bottom); 56 | var width, height; 57 | 58 | if (rectA.right <= rectB.left || 59 | rectB.right <= rectA.left || 60 | rectA.bottom <= rectB.top || 61 | rectB.bottom <= rectA.top) { 62 | x = y = width = height = 0; 63 | } else { 64 | width = Math.max(0, right - x); 65 | height = Math.max(0, bottom - y); 66 | } 67 | 68 | return new Rectangle(x, y, width, height); 69 | }; 70 | 71 | Rectangle.ofView = function (view, boxModel) { 72 | return this.ofElement(get(view, 'element'), boxModel); 73 | }; 74 | 75 | Rectangle.ofElement = function (element, boxModel) { 76 | var size = getLayout(element); 77 | if (boxModel) { 78 | size = size[boxModel]; 79 | } 80 | var offset = $(element).offset() || { top: $(element).scrollTop(), left: $(element).scrollLeft() }; 81 | 82 | return new Rectangle(offset.left, offset.top, size.width, size.height); 83 | }; 84 | 85 | export default Rectangle; 86 | -------------------------------------------------------------------------------- /addon/system/target.js: -------------------------------------------------------------------------------- 1 | import Ember from "ember"; 2 | 3 | const keys = Ember.keys; 4 | const copy = Ember.copy; 5 | const get = Ember.get; 6 | const set = Ember.set; 7 | 8 | const computed = Ember.computed; 9 | 10 | const generateGuid = Ember.generateGuid; 11 | 12 | const fmt = Ember.String.fmt; 13 | const w = Ember.String.w; 14 | 15 | const bind = Ember.run.bind; 16 | const next = Ember.run.next; 17 | 18 | const isSimpleClick = Ember.ViewUtils.isSimpleClick; 19 | const $ = Ember.$; 20 | 21 | function guard (fn) { 22 | return function (evt) { 23 | if (get(this, 'component.disabled')) { return; } 24 | fn.call(this, evt); 25 | }; 26 | } 27 | 28 | function getElementForTarget(target) { 29 | if (Ember.View.detectInstance(target)) { 30 | return get(target, 'element'); 31 | } else if (typeof target === "string") { 32 | return document.getElementById(target); 33 | } else { 34 | return target; 35 | } 36 | } 37 | 38 | function getLabelSelector($element) { 39 | var id = $element.attr('id'); 40 | if (id) { 41 | return fmt("label[for='%@']", [id]); 42 | } 43 | } 44 | 45 | function getNearestViewForElement(element) { 46 | var $target = $(element); 47 | if (!$target.hasClass('ember-view')) { 48 | $target = $target.parents('ember-view'); 49 | } 50 | return Ember.View.views[$target.attr('id')]; 51 | } 52 | 53 | function labelForEvent(evt) { 54 | var $target = $(evt.target); 55 | if ($target[0].tagName.toLowerCase() === 'label') { 56 | return $target; 57 | } else { 58 | return $target.parents('label'); 59 | } 60 | } 61 | 62 | function isLabelClicked(target, label) { 63 | if (label == null) { 64 | return false; 65 | } 66 | return $(label).attr('for') === $(target).attr('id'); 67 | } 68 | 69 | const VALID_ACTIVATORS = ["focus", "hover", "click", "hold"]; 70 | function parseActivators(value) { 71 | if (value) { 72 | var activators = value; 73 | if (typeof value === "string") { 74 | activators = Ember.A(w(value)); 75 | } 76 | Ember.assert( 77 | fmt("%@ are not valid activators.\n" + 78 | "Valid activators are %@", [value, VALID_ACTIVATORS.join(', ')]), 79 | Ember.A(copy(activators)).removeObjects(VALID_ACTIVATORS).length === 0 80 | ); 81 | return activators; 82 | } 83 | 84 | Ember.assert( 85 | fmt("You must provide an event name to the {{popup-menu}}.\n" + 86 | "Valid events are %@", [VALID_ACTIVATORS.join(', ')]), 87 | false 88 | ); 89 | } 90 | 91 | function poll(target, scope, fn) { 92 | if (getElementForTarget(target)) { 93 | scope[fn](); 94 | } else { 95 | next(null, poll, target, scope, fn); 96 | } 97 | } 98 | 99 | 100 | var Target = Ember.Object.extend(Ember.Evented, { 101 | 102 | init: function () { 103 | var target = get(this, 'target'); 104 | Ember.assert("You cannot make the {{popup-menu}} a target of itself.", get(this, 'component') !== target); 105 | 106 | this.eventManager = { 107 | focusin: bind(this, 'focus'), 108 | focusout: bind(this, 'blur'), 109 | mouseenter: bind(this, 'mouseEnter'), 110 | mouseleave: bind(this, 'mouseLeave'), 111 | mousedown: bind(this, 'mouseDown') 112 | }; 113 | 114 | if (Ember.View.detectInstance(target)) { 115 | if (get(target, 'element')) { 116 | this.attach(); 117 | } else { 118 | target.one('didInsertElement', this, 'attach'); 119 | } 120 | } else if (typeof target === 'string') { 121 | poll(target, this, 'attach'); 122 | } 123 | }, 124 | 125 | attach: function () { 126 | var element = getElementForTarget(this.target); 127 | var $element = $(element); 128 | var $document = $(document); 129 | 130 | // Already attached or awaiting an element to exist 131 | if (get(this, 'attached') || element == null) { return; } 132 | 133 | set(this, 'attached', true); 134 | set(this, 'element', element); 135 | 136 | var id = $element.attr('id'); 137 | if (id == null) { 138 | id = generateGuid(); 139 | $element.attr('id', id); 140 | } 141 | 142 | var eventManager = this.eventManager; 143 | 144 | keys(eventManager).forEach(function (event) { 145 | $document.on(event, '#' + id, eventManager[event]); 146 | }); 147 | 148 | var selector = getLabelSelector($element); 149 | if (selector) { 150 | keys(eventManager).forEach(function (event) { 151 | $document.on(event, selector, eventManager[event]); 152 | }); 153 | } 154 | }, 155 | 156 | detach: function () { 157 | var element = this.element; 158 | var $element = $(element); 159 | var $document = $(document); 160 | 161 | var eventManager = this.eventManager; 162 | 163 | var id = $element.attr('id'); 164 | keys(eventManager).forEach(function (event) { 165 | $document.off(event, '#' + id, eventManager[event]); 166 | }); 167 | 168 | var selector = getLabelSelector($element); 169 | if (selector) { 170 | keys(eventManager).forEach(function (event) { 171 | $document.off(event, selector, eventManager[event]); 172 | }); 173 | } 174 | 175 | // Remove references for GC 176 | this.eventManager = null; 177 | set(this, 'element', null); 178 | set(this, 'target', null); 179 | set(this, 'component', null); 180 | }, 181 | 182 | on: computed(function (key, value) { 183 | return parseActivators(value); 184 | }), 185 | 186 | isClicked: function (evt) { 187 | if (isSimpleClick(evt)) { 188 | var label = labelForEvent(evt); 189 | var element = this.element; 190 | return evt.target === element || $.contains(element, evt.target) || 191 | isLabelClicked(element, label); 192 | } 193 | return false; 194 | }, 195 | 196 | isActive: computed('focused', 'hovered', 'active', 'component.hovered', 'component.active', function (key, value) { 197 | var activators = get(this, 'on'); 198 | // Set 199 | if (arguments.length > 1) { 200 | if (value) { 201 | if (activators.contains('focus')) { 202 | set(this, 'focused', true); 203 | } else if (activators.contains('hover')) { 204 | set(this, 'hovered', true); 205 | } else if (activators.contains('click')) { 206 | set(this, 'active', true); 207 | } 208 | } else { 209 | set(this, 'focused', false); 210 | set(this, 'hovered', false); 211 | set(this, 'active', false); 212 | } 213 | return value; 214 | } 215 | 216 | // Get 217 | var isActive = false; 218 | 219 | if (activators.contains('focus')) { 220 | isActive = isActive || get(this, 'focused'); 221 | if (activators.contains('hold')) { 222 | isActive = isActive || get(this, 'component.active'); 223 | } 224 | } 225 | 226 | if (activators.contains('hover')) { 227 | isActive = isActive || get(this, 'hovered'); 228 | if (activators.contains('hold')) { 229 | isActive = isActive || get(this, 'component.hovered'); 230 | } 231 | } 232 | 233 | if (activators.contains('click') || activators.contains('hold')) { 234 | isActive = isActive || get(this, 'active'); 235 | } 236 | 237 | return !!isActive; 238 | }), 239 | 240 | focus: guard(function () { 241 | set(this, 'focused', true); 242 | }), 243 | 244 | blur: guard(function () { 245 | set(this, 'focused', false); 246 | }), 247 | 248 | mouseEnter: guard(function () { 249 | set(this, 'hovered', true); 250 | }), 251 | 252 | mouseLeave: guard(function () { 253 | set(this, 'hovered', false); 254 | }), 255 | 256 | mouseDown: guard(function (evt) { 257 | if (!this.isClicked(evt)) { 258 | return false; 259 | } 260 | 261 | var element = this.element; 262 | var isActive = !get(this, 'isActive'); 263 | set(this, 'active', isActive); 264 | 265 | if (isActive) { 266 | this.holdStart = new Date().getTime(); 267 | 268 | var eventManager = this.eventManager; 269 | eventManager.mouseup = bind(this, 'mouseUp'); 270 | $(document).on('mouseup', eventManager.mouseup); 271 | 272 | evt.preventDefault(); 273 | } 274 | 275 | $(element).focus(); 276 | return true; 277 | }), 278 | 279 | mouseUp: function (evt) { 280 | // Remove mouseup event 281 | var eventManager = this.eventManager; 282 | $(document).off('mouseup', eventManager.mouseup); 283 | eventManager.mouseup = null; 284 | 285 | var label = labelForEvent(evt); 286 | 287 | // Treat clicks on