arnaucube
arnaucube/commonroutesAppPublic Self-hosted repository
commonroutesApp/www/lib/angular-material/modules/closure/select/select.js
www/lib/angular-material/modules/closure/select/select.jsRaw
   1/*!
   2 * Angular Material Design
   3 * https://github.com/angular/material
   4 * @license MIT
   5 * v1.1.1
   6 */
   7goog.provide('ngmaterial.components.select');
   8goog.require('ngmaterial.components.backdrop');
   9goog.require('ngmaterial.core');
  10/**
  11 * @ngdoc module
  12 * @name material.components.select
  13 */
  14
  15/***************************************************
  16
  17 ### TODO - POST RC1 ###
  18 - [ ] Abstract placement logic in $mdSelect service to $mdMenu service
  19
  20 ***************************************************/
  21
  22SelectDirective.$inject = ["$mdSelect", "$mdUtil", "$mdConstant", "$mdTheming", "$mdAria", "$compile", "$parse"];
  23SelectMenuDirective.$inject = ["$parse", "$mdUtil", "$mdConstant", "$mdTheming"];
  24OptionDirective.$inject = ["$mdButtonInkRipple", "$mdUtil"];
  25SelectProvider.$inject = ["$$interimElementProvider"];
  26var SELECT_EDGE_MARGIN = 8;
  27var selectNextId = 0;
  28var CHECKBOX_SELECTION_INDICATOR =
  29  angular.element('<div class="md-container"><div class="md-icon"></div></div>');
  30
  31angular.module('material.components.select', [
  32    'material.core',
  33    'material.components.backdrop'
  34  ])
  35  .directive('mdSelect', SelectDirective)
  36  .directive('mdSelectMenu', SelectMenuDirective)
  37  .directive('mdOption', OptionDirective)
  38  .directive('mdOptgroup', OptgroupDirective)
  39  .directive('mdSelectHeader', SelectHeaderDirective)
  40  .provider('$mdSelect', SelectProvider);
  41
  42/**
  43 * @ngdoc directive
  44 * @name mdSelect
  45 * @restrict E
  46 * @module material.components.select
  47 *
  48 * @description Displays a select box, bound to an ng-model.
  49 *
  50 * When the select is required and uses a floating label, then the label will automatically contain
  51 * an asterisk (`*`). This behavior can be disabled by using the `md-no-asterisk` attribute.
  52 *
  53 * By default, the select will display with an underline to match other form elements. This can be
  54 * disabled by applying the `md-no-underline` CSS class.
  55 *
  56 * ### Option Params
  57 *
  58 * When applied, `md-option-empty` will mark the option as "empty" allowing the option to  clear the
  59 * select and put it back in it's default state. You may supply this attribute on any option you
  60 * wish, however, it is automatically applied to an option whose `value` or `ng-value` are not
  61 * defined.
  62 *
  63 * **Automatically Applied**
  64 *
  65 *  - `<md-option>`
  66 *  - `<md-option value>`
  67 *  - `<md-option value="">`
  68 *  - `<md-option ng-value>`
  69 *  - `<md-option ng-value="">`
  70 *
  71 * **NOT Automatically Applied**
  72 *
  73 *  - `<md-option ng-value="1">`
  74 *  - `<md-option ng-value="''">`
  75 *  - `<md-option ng-value="undefined">`
  76 *  - `<md-option value="undefined">` (this evaluates to the string `"undefined"`)
  77 *  - <code ng-non-bindable>&lt;md-option ng-value="{{someValueThatMightBeUndefined}}"&gt;</code>
  78 *
  79 * **Note:** A value of `undefined` ***is considered a valid value*** (and does not auto-apply this
  80 * attribute) since you may wish this to be your "Not Available" or "None" option.
  81 *
  82 * **Note:** Using the `value` attribute (as opposed to `ng-value`) always evaluates to a string, so
  83 * `value="null"` will require the test `ng-if="myValue != 'null'"` rather than `ng-if="!myValue"`.
  84 *
  85 * @param {expression} ng-model The model!
  86 * @param {boolean=} multiple Whether it's multiple.
  87 * @param {expression=} md-on-close Expression to be evaluated when the select is closed.
  88 * @param {expression=} md-on-open Expression to be evaluated when opening the select.
  89 * Will hide the select options and show a spinner until the evaluated promise resolves.
  90 * @param {expression=} md-selected-text Expression to be evaluated that will return a string
  91 * to be displayed as a placeholder in the select input box when it is closed.
  92 * @param {string=} placeholder Placeholder hint text.
  93 * @param md-no-asterisk {boolean=} When set to true, an asterisk will not be appended to the
  94 * floating label. **Note:** This attribute is only evaluated once; it is not watched.
  95 * @param {string=} aria-label Optional label for accessibility. Only necessary if no placeholder or
  96 * explicit label is present.
  97 * @param {string=} md-container-class Class list to get applied to the `.md-select-menu-container`
  98 * element (for custom styling).
  99 *
 100 * @usage
 101 * With a placeholder (label and aria-label are added dynamically)
 102 * <hljs lang="html">
 103 *   <md-input-container>
 104 *     <md-select
 105 *       ng-model="someModel"
 106 *       placeholder="Select a state">
 107 *       <md-option ng-value="opt" ng-repeat="opt in neighborhoods2">{{ opt }}</md-option>
 108 *     </md-select>
 109 *   </md-input-container>
 110 * </hljs>
 111 *
 112 * With an explicit label
 113 * <hljs lang="html">
 114 *   <md-input-container>
 115 *     <label>State</label>
 116 *     <md-select
 117 *       ng-model="someModel">
 118 *       <md-option ng-value="opt" ng-repeat="opt in neighborhoods2">{{ opt }}</md-option>
 119 *     </md-select>
 120 *   </md-input-container>
 121 * </hljs>
 122 *
 123 * With a select-header
 124 *
 125 * When a developer needs to put more than just a text label in the
 126 * md-select-menu, they should use the md-select-header.
 127 * The user can put custom HTML inside of the header and style it to their liking.
 128 * One common use case of this would be a sticky search bar.
 129 *
 130 * When using the md-select-header the labels that would previously be added to the
 131 * OptGroupDirective are ignored.
 132 *
 133 * <hljs lang="html">
 134 *   <md-input-container>
 135 *     <md-select ng-model="someModel">
 136 *       <md-select-header>
 137 *         <span> Neighborhoods - </span>
 138 *       </md-select-header>
 139 *       <md-option ng-value="opt" ng-repeat="opt in neighborhoods2">{{ opt }}</md-option>
 140 *     </md-select>
 141 *   </md-input-container>
 142 * </hljs>
 143 *
 144 * ## Selects and object equality
 145 * When using a `md-select` to pick from a list of objects, it is important to realize how javascript handles
 146 * equality. Consider the following example:
 147 * <hljs lang="js">
 148 * angular.controller('MyCtrl', function($scope) {
 149 *   $scope.users = [
 150 *     { id: 1, name: 'Bob' },
 151 *     { id: 2, name: 'Alice' },
 152 *     { id: 3, name: 'Steve' }
 153 *   ];
 154 *   $scope.selectedUser = { id: 1, name: 'Bob' };
 155 * });
 156 * </hljs>
 157 * <hljs lang="html">
 158 * <div ng-controller="MyCtrl">
 159 *   <md-select ng-model="selectedUser">
 160 *     <md-option ng-value="user" ng-repeat="user in users">{{ user.name }}</md-option>
 161 *   </md-select>
 162 * </div>
 163 * </hljs>
 164 *
 165 * At first one might expect that the select should be populated with "Bob" as the selected user. However,
 166 * this is not true. To determine whether something is selected,
 167 * `ngModelController` is looking at whether `$scope.selectedUser == (any user in $scope.users);`;
 168 *
 169 * Javascript's `==` operator does not check for deep equality (ie. that all properties
 170 * on the object are the same), but instead whether the objects are *the same object in memory*.
 171 * In this case, we have two instances of identical objects, but they exist in memory as unique
 172 * entities. Because of this, the select will have no value populated for a selected user.
 173 *
 174 * To get around this, `ngModelController` provides a `track by` option that allows us to specify a different
 175 * expression which will be used for the equality operator. As such, we can update our `html` to
 176 * make use of this by specifying the `ng-model-options="{trackBy: '$value.id'}"` on the `md-select`
 177 * element. This converts our equality expression to be
 178 * `$scope.selectedUser.id == (any id in $scope.users.map(function(u) { return u.id; }));`
 179 * which results in Bob being selected as desired.
 180 *
 181 * Working HTML:
 182 * <hljs lang="html">
 183 * <div ng-controller="MyCtrl">
 184 *   <md-select ng-model="selectedUser" ng-model-options="{trackBy: '$value.id'}">
 185 *     <md-option ng-value="user" ng-repeat="user in users">{{ user.name }}</md-option>
 186 *   </md-select>
 187 * </div>
 188 * </hljs>
 189 */
 190function SelectDirective($mdSelect, $mdUtil, $mdConstant, $mdTheming, $mdAria, $compile, $parse) {
 191  var keyCodes = $mdConstant.KEY_CODE;
 192  var NAVIGATION_KEYS = [keyCodes.SPACE, keyCodes.ENTER, keyCodes.UP_ARROW, keyCodes.DOWN_ARROW];
 193
 194  return {
 195    restrict: 'E',
 196    require: ['^?mdInputContainer', 'mdSelect', 'ngModel', '?^form'],
 197    compile: compile,
 198    controller: function() {
 199    } // empty placeholder controller to be initialized in link
 200  };
 201
 202  function compile(element, attr) {
 203    // add the select value that will hold our placeholder or selected option value
 204    var valueEl = angular.element('<md-select-value><span></span></md-select-value>');
 205    valueEl.append('<span class="md-select-icon" aria-hidden="true"></span>');
 206    valueEl.addClass('md-select-value');
 207    if (!valueEl[0].hasAttribute('id')) {
 208      valueEl.attr('id', 'select_value_label_' + $mdUtil.nextUid());
 209    }
 210
 211    // There's got to be an md-content inside. If there's not one, let's add it.
 212    if (!element.find('md-content').length) {
 213      element.append(angular.element('<md-content>').append(element.contents()));
 214    }
 215
 216
 217    // Add progress spinner for md-options-loading
 218    if (attr.mdOnOpen) {
 219
 220      // Show progress indicator while loading async
 221      // Use ng-hide for `display:none` so the indicator does not interfere with the options list
 222      element
 223        .find('md-content')
 224        .prepend(angular.element(
 225          '<div>' +
 226          ' <md-progress-circular md-mode="indeterminate" ng-if="$$loadingAsyncDone === false" md-diameter="25px"></md-progress-circular>' +
 227          '</div>'
 228        ));
 229
 230      // Hide list [of item options] while loading async
 231      element
 232        .find('md-option')
 233        .attr('ng-show', '$$loadingAsyncDone');
 234    }
 235
 236    if (attr.name) {
 237      var autofillClone = angular.element('<select class="md-visually-hidden">');
 238      autofillClone.attr({
 239        'name': attr.name,
 240        'aria-hidden': 'true',
 241        'tabindex': '-1'
 242      });
 243      var opts = element.find('md-option');
 244      angular.forEach(opts, function(el) {
 245        var newEl = angular.element('<option>' + el.innerHTML + '</option>');
 246        if (el.hasAttribute('ng-value')) newEl.attr('ng-value', el.getAttribute('ng-value'));
 247        else if (el.hasAttribute('value')) newEl.attr('value', el.getAttribute('value'));
 248        autofillClone.append(newEl);
 249      });
 250
 251      // Adds an extra option that will hold the selected value for the
 252      // cases where the select is a part of a non-angular form. This can be done with a ng-model,
 253      // however if the `md-option` is being `ng-repeat`-ed, Angular seems to insert a similar
 254      // `option` node, but with a value of `? string: <value> ?` which would then get submitted.
 255      // This also goes around having to prepend a dot to the name attribute.
 256      autofillClone.append(
 257        '<option ng-value="' + attr.ngModel + '" selected></option>'
 258      );
 259
 260      element.parent().append(autofillClone);
 261    }
 262
 263    var isMultiple = $mdUtil.parseAttributeBoolean(attr.multiple);
 264
 265    // Use everything that's left inside element.contents() as the contents of the menu
 266    var multipleContent = isMultiple ? 'multiple' : '';
 267    var selectTemplate = '' +
 268      '<div class="md-select-menu-container" aria-hidden="true">' +
 269      '<md-select-menu {0}>{1}</md-select-menu>' +
 270      '</div>';
 271
 272    selectTemplate = $mdUtil.supplant(selectTemplate, [multipleContent, element.html()]);
 273    element.empty().append(valueEl);
 274    element.append(selectTemplate);
 275
 276    if(!attr.tabindex){
 277      attr.$set('tabindex', 0);
 278    }
 279
 280    return function postLink(scope, element, attr, ctrls) {
 281      var untouched = true;
 282      var isDisabled, ariaLabelBase;
 283
 284      var containerCtrl = ctrls[0];
 285      var mdSelectCtrl = ctrls[1];
 286      var ngModelCtrl = ctrls[2];
 287      var formCtrl = ctrls[3];
 288      // grab a reference to the select menu value label
 289      var valueEl = element.find('md-select-value');
 290      var isReadonly = angular.isDefined(attr.readonly);
 291      var disableAsterisk = $mdUtil.parseAttributeBoolean(attr.mdNoAsterisk);
 292
 293      if (disableAsterisk) {
 294        element.addClass('md-no-asterisk');
 295      }
 296
 297      if (containerCtrl) {
 298        var isErrorGetter = containerCtrl.isErrorGetter || function() {
 299          return ngModelCtrl.$invalid && (ngModelCtrl.$touched || (formCtrl && formCtrl.$submitted));
 300        };
 301
 302        if (containerCtrl.input) {
 303          // We ignore inputs that are in the md-select-header (one
 304          // case where this might be useful would be adding as searchbox)
 305          if (element.find('md-select-header').find('input')[0] !== containerCtrl.input[0]) {
 306            throw new Error("<md-input-container> can only have *one* child <input>, <textarea> or <select> element!");
 307          }
 308        }
 309
 310        containerCtrl.input = element;
 311        if (!containerCtrl.label) {
 312          $mdAria.expect(element, 'aria-label', element.attr('placeholder'));
 313        }
 314
 315        scope.$watch(isErrorGetter, containerCtrl.setInvalid);
 316      }
 317
 318      var selectContainer, selectScope, selectMenuCtrl;
 319
 320      findSelectContainer();
 321      $mdTheming(element);
 322
 323      if (formCtrl && angular.isDefined(attr.multiple)) {
 324        $mdUtil.nextTick(function() {
 325          var hasModelValue = ngModelCtrl.$modelValue || ngModelCtrl.$viewValue;
 326          if (hasModelValue) {
 327            formCtrl.$setPristine();
 328          }
 329        });
 330      }
 331
 332      var originalRender = ngModelCtrl.$render;
 333      ngModelCtrl.$render = function() {
 334        originalRender();
 335        syncLabelText();
 336        syncAriaLabel();
 337        inputCheckValue();
 338      };
 339
 340      attr.$observe('placeholder', ngModelCtrl.$render);
 341
 342      if (containerCtrl && containerCtrl.label) {
 343        attr.$observe('required', function (value) {
 344          // Toggle the md-required class on the input containers label, because the input container is automatically
 345          // applying the asterisk indicator on the label.
 346          containerCtrl.label.toggleClass('md-required', value && !disableAsterisk);
 347        });
 348      }
 349
 350      mdSelectCtrl.setLabelText = function(text) {
 351        mdSelectCtrl.setIsPlaceholder(!text);
 352
 353        if (attr.mdSelectedText) {
 354          text = $parse(attr.mdSelectedText)(scope);
 355        } else {
 356          // Use placeholder attribute, otherwise fallback to the md-input-container label
 357          var tmpPlaceholder = attr.placeholder ||
 358              (containerCtrl && containerCtrl.label ? containerCtrl.label.text() : '');
 359          text = text || tmpPlaceholder || '';
 360        }
 361
 362        var target = valueEl.children().eq(0);
 363        target.html(text);
 364      };
 365
 366      mdSelectCtrl.setIsPlaceholder = function(isPlaceholder) {
 367        if (isPlaceholder) {
 368          valueEl.addClass('md-select-placeholder');
 369          if (containerCtrl && containerCtrl.label) {
 370            containerCtrl.label.addClass('md-placeholder');
 371          }
 372        } else {
 373          valueEl.removeClass('md-select-placeholder');
 374          if (containerCtrl && containerCtrl.label) {
 375            containerCtrl.label.removeClass('md-placeholder');
 376          }
 377        }
 378      };
 379
 380      if (!isReadonly) {
 381        element
 382          .on('focus', function(ev) {
 383            // Always focus the container (if we have one) so floating labels and other styles are
 384            // applied properly
 385            containerCtrl && containerCtrl.setFocused(true);
 386          });
 387
 388        // Attach before ngModel's blur listener to stop propagation of blur event
 389        // to prevent from setting $touched.
 390        element.on('blur', function(event) {
 391          if (untouched) {
 392            untouched = false;
 393            if (selectScope._mdSelectIsOpen) {
 394              event.stopImmediatePropagation();
 395            }
 396          }
 397
 398          if (selectScope._mdSelectIsOpen) return;
 399          containerCtrl && containerCtrl.setFocused(false);
 400          inputCheckValue();
 401        });
 402      }
 403
 404      mdSelectCtrl.triggerClose = function() {
 405        $parse(attr.mdOnClose)(scope);
 406      };
 407
 408      scope.$$postDigest(function() {
 409        initAriaLabel();
 410        syncLabelText();
 411        syncAriaLabel();
 412      });
 413
 414      function initAriaLabel() {
 415        var labelText = element.attr('aria-label') || element.attr('placeholder');
 416        if (!labelText && containerCtrl && containerCtrl.label) {
 417          labelText = containerCtrl.label.text();
 418        }
 419        ariaLabelBase = labelText;
 420        $mdAria.expect(element, 'aria-label', labelText);
 421      }
 422
 423      scope.$watch(function() {
 424          return selectMenuCtrl.selectedLabels();
 425      }, syncLabelText);
 426
 427      function syncLabelText() {
 428        if (selectContainer) {
 429          selectMenuCtrl = selectMenuCtrl || selectContainer.find('md-select-menu').controller('mdSelectMenu');
 430          mdSelectCtrl.setLabelText(selectMenuCtrl.selectedLabels());
 431        }
 432      }
 433
 434      function syncAriaLabel() {
 435        if (!ariaLabelBase) return;
 436        var ariaLabels = selectMenuCtrl.selectedLabels({mode: 'aria'});
 437        element.attr('aria-label', ariaLabels.length ? ariaLabelBase + ': ' + ariaLabels : ariaLabelBase);
 438      }
 439
 440      var deregisterWatcher;
 441      attr.$observe('ngMultiple', function(val) {
 442        if (deregisterWatcher) deregisterWatcher();
 443        var parser = $parse(val);
 444        deregisterWatcher = scope.$watch(function() {
 445          return parser(scope);
 446        }, function(multiple, prevVal) {
 447          if (multiple === undefined && prevVal === undefined) return; // assume compiler did a good job
 448          if (multiple) {
 449            element.attr('multiple', 'multiple');
 450          } else {
 451            element.removeAttr('multiple');
 452          }
 453          element.attr('aria-multiselectable', multiple ? 'true' : 'false');
 454          if (selectContainer) {
 455            selectMenuCtrl.setMultiple(multiple);
 456            originalRender = ngModelCtrl.$render;
 457            ngModelCtrl.$render = function() {
 458              originalRender();
 459              syncLabelText();
 460              syncAriaLabel();
 461              inputCheckValue();
 462            };
 463            ngModelCtrl.$render();
 464          }
 465        });
 466      });
 467
 468      attr.$observe('disabled', function(disabled) {
 469        if (angular.isString(disabled)) {
 470          disabled = true;
 471        }
 472        // Prevent click event being registered twice
 473        if (isDisabled !== undefined && isDisabled === disabled) {
 474          return;
 475        }
 476        isDisabled = disabled;
 477        if (disabled) {
 478          element
 479            .attr({'aria-disabled': 'true'})
 480            .removeAttr('tabindex')
 481            .off('click', openSelect)
 482            .off('keydown', handleKeypress);
 483        } else {
 484          element
 485            .attr({'tabindex': attr.tabindex, 'aria-disabled': 'false'})
 486            .on('click', openSelect)
 487            .on('keydown', handleKeypress);
 488        }
 489      });
 490
 491      if (!attr.hasOwnProperty('disabled') && !attr.hasOwnProperty('ngDisabled')) {
 492        element.attr({'aria-disabled': 'false'});
 493        element.on('click', openSelect);
 494        element.on('keydown', handleKeypress);
 495      }
 496
 497      var ariaAttrs = {
 498        role: 'listbox',
 499        'aria-expanded': 'false',
 500        'aria-multiselectable': isMultiple && !attr.ngMultiple ? 'true' : 'false'
 501      };
 502
 503      if (!element[0].hasAttribute('id')) {
 504        ariaAttrs.id = 'select_' + $mdUtil.nextUid();
 505      }
 506
 507      var containerId = 'select_container_' + $mdUtil.nextUid();
 508      selectContainer.attr('id', containerId);
 509      ariaAttrs['aria-owns'] = containerId;
 510      element.attr(ariaAttrs);
 511
 512      scope.$on('$destroy', function() {
 513        $mdSelect
 514          .destroy()
 515          .finally(function() {
 516            if (containerCtrl) {
 517              containerCtrl.setFocused(false);
 518              containerCtrl.setHasValue(false);
 519              containerCtrl.input = null;
 520            }
 521            ngModelCtrl.$setTouched();
 522          });
 523      });
 524
 525
 526
 527      function inputCheckValue() {
 528        // The select counts as having a value if one or more options are selected,
 529        // or if the input's validity state says it has bad input (eg string in a number input)
 530        containerCtrl && containerCtrl.setHasValue(selectMenuCtrl.selectedLabels().length > 0 || (element[0].validity || {}).badInput);
 531      }
 532
 533      function findSelectContainer() {
 534        selectContainer = angular.element(
 535          element[0].querySelector('.md-select-menu-container')
 536        );
 537        selectScope = scope;
 538        if (attr.mdContainerClass) {
 539          var value = selectContainer[0].getAttribute('class') + ' ' + attr.mdContainerClass;
 540          selectContainer[0].setAttribute('class', value);
 541        }
 542        selectMenuCtrl = selectContainer.find('md-select-menu').controller('mdSelectMenu');
 543        selectMenuCtrl.init(ngModelCtrl, attr.ngModel);
 544        element.on('$destroy', function() {
 545          selectContainer.remove();
 546        });
 547      }
 548
 549      function handleKeypress(e) {
 550        if ($mdConstant.isNavigationKey(e)) {
 551          // prevent page scrolling on interaction
 552          e.preventDefault();
 553          openSelect(e);
 554        } else {
 555          if ($mdConstant.isInputKey(e) || $mdConstant.isNumPadKey(e)) {
 556            e.preventDefault();
 557
 558            var node = selectMenuCtrl.optNodeForKeyboardSearch(e);
 559            if (!node || node.hasAttribute('disabled')) return;
 560            var optionCtrl = angular.element(node).controller('mdOption');
 561            if (!selectMenuCtrl.isMultiple) {
 562              selectMenuCtrl.deselect(Object.keys(selectMenuCtrl.selected)[0]);
 563            }
 564            selectMenuCtrl.select(optionCtrl.hashKey, optionCtrl.value);
 565            selectMenuCtrl.refreshViewValue();
 566          }
 567        }
 568      }
 569
 570      function openSelect() {
 571        selectScope._mdSelectIsOpen = true;
 572        element.attr('aria-expanded', 'true');
 573
 574        $mdSelect.show({
 575          scope: selectScope,
 576          preserveScope: true,
 577          skipCompile: true,
 578          element: selectContainer,
 579          target: element[0],
 580          selectCtrl: mdSelectCtrl,
 581          preserveElement: true,
 582          hasBackdrop: true,
 583          loadingAsync: attr.mdOnOpen ? scope.$eval(attr.mdOnOpen) || true : false
 584        }).finally(function() {
 585          selectScope._mdSelectIsOpen = false;
 586          element.focus();
 587          element.attr('aria-expanded', 'false');
 588          ngModelCtrl.$setTouched();
 589        });
 590      }
 591
 592    };
 593  }
 594}
 595
 596function SelectMenuDirective($parse, $mdUtil, $mdConstant, $mdTheming) {
 597  // We want the scope to be set to 'false' so an isolated scope is not created
 598  // which would interfere with the md-select-header's access to the
 599  // parent scope.
 600  SelectMenuController.$inject = ["$scope", "$attrs", "$element"];
 601  return {
 602    restrict: 'E',
 603    require: ['mdSelectMenu'],
 604    scope: false,
 605    controller: SelectMenuController,
 606    link: {pre: preLink}
 607  };
 608
 609  // We use preLink instead of postLink to ensure that the select is initialized before
 610  // its child options run postLink.
 611  function preLink(scope, element, attr, ctrls) {
 612    var selectCtrl = ctrls[0];
 613
 614    element.addClass('_md');     // private md component indicator for styling
 615
 616    $mdTheming(element);
 617    element.on('click', clickListener);
 618    element.on('keypress', keyListener);
 619
 620    function keyListener(e) {
 621      if (e.keyCode == 13 || e.keyCode == 32) {
 622        clickListener(e);
 623      }
 624    }
 625
 626    function clickListener(ev) {
 627      var option = $mdUtil.getClosest(ev.target, 'md-option');
 628      var optionCtrl = option && angular.element(option).data('$mdOptionController');
 629      if (!option || !optionCtrl) return;
 630      if (option.hasAttribute('disabled')) {
 631        ev.stopImmediatePropagation();
 632        return false;
 633      }
 634
 635      var optionHashKey = selectCtrl.hashGetter(optionCtrl.value);
 636      var isSelected = angular.isDefined(selectCtrl.selected[optionHashKey]);
 637
 638      scope.$apply(function() {
 639        if (selectCtrl.isMultiple) {
 640          if (isSelected) {
 641            selectCtrl.deselect(optionHashKey);
 642          } else {
 643            selectCtrl.select(optionHashKey, optionCtrl.value);
 644          }
 645        } else {
 646          if (!isSelected) {
 647            selectCtrl.deselect(Object.keys(selectCtrl.selected)[0]);
 648            selectCtrl.select(optionHashKey, optionCtrl.value);
 649          }
 650        }
 651        selectCtrl.refreshViewValue();
 652      });
 653    }
 654  }
 655
 656  function SelectMenuController($scope, $attrs, $element) {
 657    var self = this;
 658    self.isMultiple = angular.isDefined($attrs.multiple);
 659    // selected is an object with keys matching all of the selected options' hashed values
 660    self.selected = {};
 661    // options is an object with keys matching every option's hash value,
 662    // and values matching every option's controller.
 663    self.options = {};
 664
 665    $scope.$watchCollection(function() {
 666      return self.options;
 667    }, function() {
 668      self.ngModel.$render();
 669    });
 670
 671    var deregisterCollectionWatch;
 672    var defaultIsEmpty;
 673    self.setMultiple = function(isMultiple) {
 674      var ngModel = self.ngModel;
 675      defaultIsEmpty = defaultIsEmpty || ngModel.$isEmpty;
 676
 677      self.isMultiple = isMultiple;
 678      if (deregisterCollectionWatch) deregisterCollectionWatch();
 679
 680      if (self.isMultiple) {
 681        ngModel.$validators['md-multiple'] = validateArray;
 682        ngModel.$render = renderMultiple;
 683
 684        // watchCollection on the model because by default ngModel only watches the model's
 685        // reference. This allowed the developer to also push and pop from their array.
 686        $scope.$watchCollection(self.modelBinding, function(value) {
 687          if (validateArray(value)) renderMultiple(value);
 688          self.ngModel.$setPristine();
 689        });
 690
 691        ngModel.$isEmpty = function(value) {
 692          return !value || value.length === 0;
 693        };
 694      } else {
 695        delete ngModel.$validators['md-multiple'];
 696        ngModel.$render = renderSingular;
 697      }
 698
 699      function validateArray(modelValue, viewValue) {
 700        // If a value is truthy but not an array, reject it.
 701        // If value is undefined/falsy, accept that it's an empty array.
 702        return angular.isArray(modelValue || viewValue || []);
 703      }
 704    };
 705
 706    var searchStr = '';
 707    var clearSearchTimeout, optNodes, optText;
 708    var CLEAR_SEARCH_AFTER = 300;
 709
 710    self.optNodeForKeyboardSearch = function(e) {
 711      clearSearchTimeout && clearTimeout(clearSearchTimeout);
 712      clearSearchTimeout = setTimeout(function() {
 713        clearSearchTimeout = undefined;
 714        searchStr = '';
 715        optText = undefined;
 716        optNodes = undefined;
 717      }, CLEAR_SEARCH_AFTER);
 718
 719      // Support 1-9 on numpad
 720      var keyCode = e.keyCode - ($mdConstant.isNumPadKey(e) ? 48 : 0);
 721
 722      searchStr += String.fromCharCode(keyCode);
 723      var search = new RegExp('^' + searchStr, 'i');
 724      if (!optNodes) {
 725        optNodes = $element.find('md-option');
 726        optText = new Array(optNodes.length);
 727        angular.forEach(optNodes, function(el, i) {
 728          optText[i] = el.textContent.trim();
 729        });
 730      }
 731      for (var i = 0; i < optText.length; ++i) {
 732        if (search.test(optText[i])) {
 733          return optNodes[i];
 734        }
 735      }
 736    };
 737
 738    self.init = function(ngModel, binding) {
 739      self.ngModel = ngModel;
 740      self.modelBinding = binding;
 741
 742      // Setup a more robust version of isEmpty to ensure value is a valid option
 743      self.ngModel.$isEmpty = function($viewValue) {
 744        // We have to transform the viewValue into the hashKey, because otherwise the
 745        // OptionCtrl may not exist. Developers may have specified a trackBy function.
 746        return !self.options[self.hashGetter($viewValue)];
 747      };
 748
 749      // Allow users to provide `ng-model="foo" ng-model-options="{trackBy: 'foo.id'}"` so
 750      // that we can properly compare objects set on the model to the available options
 751      if (ngModel.$options && ngModel.$options.trackBy) {
 752        var trackByLocals = {};
 753        var trackByParsed = $parse(ngModel.$options.trackBy);
 754        self.hashGetter = function(value, valueScope) {
 755          trackByLocals.$value = value;
 756          return trackByParsed(valueScope || $scope, trackByLocals);
 757        };
 758        // If the user doesn't provide a trackBy, we automatically generate an id for every
 759        // value passed in
 760      } else {
 761        self.hashGetter = function getHashValue(value) {
 762          if (angular.isObject(value)) {
 763            return 'object_' + (value.$$mdSelectId || (value.$$mdSelectId = ++selectNextId));
 764          }
 765          return value;
 766        };
 767      }
 768      self.setMultiple(self.isMultiple);
 769    };
 770
 771    self.selectedLabels = function(opts) {
 772      opts = opts || {};
 773      var mode = opts.mode || 'html';
 774      var selectedOptionEls = $mdUtil.nodesToArray($element[0].querySelectorAll('md-option[selected]'));
 775      if (selectedOptionEls.length) {
 776        var mapFn;
 777
 778        if (mode == 'html') {
 779          // Map the given element to its innerHTML string. If the element has a child ripple
 780          // container remove it from the HTML string, before returning the string.
 781          mapFn = function(el) {
 782            // If we do not have a `value` or `ng-value`, assume it is an empty option which clears the select
 783            if (el.hasAttribute('md-option-empty')) {
 784              return '';
 785            }
 786
 787            var html = el.innerHTML;
 788
 789            // Remove the ripple container from the selected option, copying it would cause a CSP violation.
 790            var rippleContainer = el.querySelector('.md-ripple-container');
 791            if (rippleContainer) {
 792              html = html.replace(rippleContainer.outerHTML, '');
 793            }
 794
 795            // Remove the checkbox container, because it will cause the label to wrap inside of the placeholder.
 796            // It should be not displayed inside of the label element.
 797            var checkboxContainer = el.querySelector('.md-container');
 798            if (checkboxContainer) {
 799              html = html.replace(checkboxContainer.outerHTML, '');
 800            }
 801
 802            return html;
 803          };
 804        } else if (mode == 'aria') {
 805          mapFn = function(el) { return el.hasAttribute('aria-label') ? el.getAttribute('aria-label') : el.textContent; };
 806        }
 807        return selectedOptionEls.map(mapFn).join(', ');
 808      } else {
 809        return '';
 810      }
 811    };
 812
 813    self.select = function(hashKey, hashedValue) {
 814      var option = self.options[hashKey];
 815      option && option.setSelected(true);
 816      self.selected[hashKey] = hashedValue;
 817    };
 818    self.deselect = function(hashKey) {
 819      var option = self.options[hashKey];
 820      option && option.setSelected(false);
 821      delete self.selected[hashKey];
 822    };
 823
 824    self.addOption = function(hashKey, optionCtrl) {
 825      if (angular.isDefined(self.options[hashKey])) {
 826        throw new Error('Duplicate md-option values are not allowed in a select. ' +
 827          'Duplicate value "' + optionCtrl.value + '" found.');
 828      }
 829
 830      self.options[hashKey] = optionCtrl;
 831
 832      // If this option's value was already in our ngModel, go ahead and select it.
 833      if (angular.isDefined(self.selected[hashKey])) {
 834        self.select(hashKey, optionCtrl.value);
 835
 836        // When the current $modelValue of the ngModel Controller is using the same hash as
 837        // the current option, which will be added, then we can be sure, that the validation
 838        // of the option has occurred before the option was added properly.
 839        // This means, that we have to manually trigger a new validation of the current option.
 840        if (angular.isDefined(self.ngModel.$modelValue) && self.hashGetter(self.ngModel.$modelValue) === hashKey) {
 841          self.ngModel.$validate();
 842        }
 843
 844        self.refreshViewValue();
 845      }
 846    };
 847    self.removeOption = function(hashKey) {
 848      delete self.options[hashKey];
 849      // Don't deselect an option when it's removed - the user's ngModel should be allowed
 850      // to have values that do not match a currently available option.
 851    };
 852
 853    self.refreshViewValue = function() {
 854      var values = [];
 855      var option;
 856      for (var hashKey in self.selected) {
 857        // If this hashKey has an associated option, push that option's value to the model.
 858        if ((option = self.options[hashKey])) {
 859          values.push(option.value);
 860        } else {
 861          // Otherwise, the given hashKey has no associated option, and we got it
 862          // from an ngModel value at an earlier time. Push the unhashed value of
 863          // this hashKey to the model.
 864          // This allows the developer to put a value in the model that doesn't yet have
 865          // an associated option.
 866          values.push(self.selected[hashKey]);
 867        }
 868      }
 869      var usingTrackBy = self.ngModel.$options && self.ngModel.$options.trackBy;
 870
 871      var newVal = self.isMultiple ? values : values[0];
 872      var prevVal = self.ngModel.$modelValue;
 873
 874      if (usingTrackBy ? !angular.equals(prevVal, newVal) : prevVal != newVal) {
 875        self.ngModel.$setViewValue(newVal);
 876        self.ngModel.$render();
 877      }
 878    };
 879
 880    function renderMultiple() {
 881      var newSelectedValues = self.ngModel.$modelValue || self.ngModel.$viewValue || [];
 882      if (!angular.isArray(newSelectedValues)) return;
 883
 884      var oldSelected = Object.keys(self.selected);
 885
 886      var newSelectedHashes = newSelectedValues.map(self.hashGetter);
 887      var deselected = oldSelected.filter(function(hash) {
 888        return newSelectedHashes.indexOf(hash) === -1;
 889      });
 890
 891      deselected.forEach(self.deselect);
 892      newSelectedHashes.forEach(function(hashKey, i) {
 893        self.select(hashKey, newSelectedValues[i]);
 894      });
 895    }
 896
 897    function renderSingular() {
 898      var value = self.ngModel.$viewValue || self.ngModel.$modelValue;
 899      Object.keys(self.selected).forEach(self.deselect);
 900      self.select(self.hashGetter(value), value);
 901    }
 902  }
 903
 904}
 905
 906function OptionDirective($mdButtonInkRipple, $mdUtil) {
 907
 908  OptionController.$inject = ["$element"];
 909  return {
 910    restrict: 'E',
 911    require: ['mdOption', '^^mdSelectMenu'],
 912    controller: OptionController,
 913    compile: compile
 914  };
 915
 916  function compile(element, attr) {
 917    // Manual transclusion to avoid the extra inner <span> that ng-transclude generates
 918    element.append(angular.element('<div class="md-text">').append(element.contents()));
 919
 920    element.attr('tabindex', attr.tabindex || '0');
 921
 922    if (!hasDefinedValue(attr)) {
 923      element.attr('md-option-empty', '');
 924    }
 925
 926    return postLink;
 927  }
 928
 929  function hasDefinedValue(attr) {
 930    var value = attr.value;
 931    var ngValue = attr.ngValue;
 932
 933    return value || ngValue;
 934  }
 935
 936  function postLink(scope, element, attr, ctrls) {
 937    var optionCtrl = ctrls[0];
 938    var selectCtrl = ctrls[1];
 939
 940    if (selectCtrl.isMultiple) {
 941      element.addClass('md-checkbox-enabled');
 942      element.prepend(CHECKBOX_SELECTION_INDICATOR.clone());
 943    }
 944
 945    if (angular.isDefined(attr.ngValue)) {
 946      scope.$watch(attr.ngValue, setOptionValue);
 947    } else if (angular.isDefined(attr.value)) {
 948      setOptionValue(attr.value);
 949    } else {
 950      scope.$watch(function() {
 951        return element.text().trim();
 952      }, setOptionValue);
 953    }
 954
 955    attr.$observe('disabled', function(disabled) {
 956      if (disabled) {
 957        element.attr('tabindex', '-1');
 958      } else {
 959        element.attr('tabindex', '0');
 960      }
 961    });
 962
 963    scope.$$postDigest(function() {
 964      attr.$observe('selected', function(selected) {
 965        if (!angular.isDefined(selected)) return;
 966        if (typeof selected == 'string') selected = true;
 967        if (selected) {
 968          if (!selectCtrl.isMultiple) {
 969            selectCtrl.deselect(Object.keys(selectCtrl.selected)[0]);
 970          }
 971          selectCtrl.select(optionCtrl.hashKey, optionCtrl.value);
 972        } else {
 973          selectCtrl.deselect(optionCtrl.hashKey);
 974        }
 975        selectCtrl.refreshViewValue();
 976      });
 977    });
 978
 979    $mdButtonInkRipple.attach(scope, element);
 980    configureAria();
 981
 982    function setOptionValue(newValue, oldValue, prevAttempt) {
 983      if (!selectCtrl.hashGetter) {
 984        if (!prevAttempt) {
 985          scope.$$postDigest(function() {
 986            setOptionValue(newValue, oldValue, true);
 987          });
 988        }
 989        return;
 990      }
 991      var oldHashKey = selectCtrl.hashGetter(oldValue, scope);
 992      var newHashKey = selectCtrl.hashGetter(newValue, scope);
 993
 994      optionCtrl.hashKey = newHashKey;
 995      optionCtrl.value = newValue;
 996
 997      selectCtrl.removeOption(oldHashKey, optionCtrl);
 998      selectCtrl.addOption(newHashKey, optionCtrl);
 999    }
1000
1001    scope.$on('$destroy', function() {
1002      selectCtrl.removeOption(optionCtrl.hashKey, optionCtrl);
1003    });
1004
1005    function configureAria() {
1006      var ariaAttrs = {
1007        'role': 'option',
1008        'aria-selected': 'false'
1009      };
1010
1011      if (!element[0].hasAttribute('id')) {
1012        ariaAttrs.id = 'select_option_' + $mdUtil.nextUid();
1013      }
1014      element.attr(ariaAttrs);
1015    }
1016  }
1017
1018  function OptionController($element) {
1019    this.selected = false;
1020    this.setSelected = function(isSelected) {
1021      if (isSelected && !this.selected) {
1022        $element.attr({
1023          'selected': 'selected',
1024          'aria-selected': 'true'
1025        });
1026      } else if (!isSelected && this.selected) {
1027        $element.removeAttr('selected');
1028        $element.attr('aria-selected', 'false');
1029      }
1030      this.selected = isSelected;
1031    };
1032  }
1033
1034}
1035
1036function OptgroupDirective() {
1037  return {
1038    restrict: 'E',
1039    compile: compile
1040  };
1041  function compile(el, attrs) {
1042    // If we have a select header element, we don't want to add the normal label
1043    // header.
1044    if (!hasSelectHeader()) {
1045      setupLabelElement();
1046    }
1047
1048    function hasSelectHeader() {
1049      return el.parent().find('md-select-header').length;
1050    }
1051
1052    function setupLabelElement() {
1053      var labelElement = el.find('label');
1054      if (!labelElement.length) {
1055        labelElement = angular.element('<label>');
1056        el.prepend(labelElement);
1057      }
1058      labelElement.addClass('md-container-ignore');
1059      if (attrs.label) labelElement.text(attrs.label);
1060    }
1061  }
1062}
1063
1064function SelectHeaderDirective() {
1065  return {
1066    restrict: 'E',
1067  };
1068}
1069
1070function SelectProvider($$interimElementProvider) {
1071  selectDefaultOptions.$inject = ["$mdSelect", "$mdConstant", "$mdUtil", "$window", "$q", "$$rAF", "$animateCss", "$animate", "$document"];
1072  return $$interimElementProvider('$mdSelect')
1073    .setDefaults({
1074      methods: ['target'],
1075      options: selectDefaultOptions
1076    });
1077
1078  /* ngInject */
1079  function selectDefaultOptions($mdSelect, $mdConstant, $mdUtil, $window, $q, $$rAF, $animateCss, $animate, $document) {
1080    var ERROR_TARGET_EXPECTED = "$mdSelect.show() expected a target element in options.target but got '{0}'!";
1081    var animator = $mdUtil.dom.animator;
1082    var keyCodes = $mdConstant.KEY_CODE;
1083
1084    return {
1085      parent: 'body',
1086      themable: true,
1087      onShow: onShow,
1088      onRemove: onRemove,
1089      hasBackdrop: true,
1090      disableParentScroll: true
1091    };
1092
1093    /**
1094     * Interim-element onRemove logic....
1095     */
1096    function onRemove(scope, element, opts) {
1097      opts = opts || { };
1098      opts.cleanupInteraction();
1099      opts.cleanupResizing();
1100      opts.hideBackdrop();
1101
1102      // For navigation $destroy events, do a quick, non-animated removal,
1103      // but for normal closes (from clicks, etc) animate the removal
1104
1105      return  (opts.$destroy === true) ? cleanElement() : animateRemoval().then( cleanElement );
1106
1107      /**
1108       * For normal closes (eg clicks), animate the removal.
1109       * For forced closes (like $destroy events from navigation),
1110       * skip the animations
1111       */
1112      function animateRemoval() {
1113        return $animateCss(element, {addClass: 'md-leave'}).start();
1114      }
1115
1116      /**
1117       * Restore the element to a closed state
1118       */
1119      function cleanElement() {
1120
1121        element.removeClass('md-active');
1122        element.attr('aria-hidden', 'true');
1123        element[0].style.display = 'none';
1124
1125        announceClosed(opts);
1126
1127        if (!opts.$destroy && opts.restoreFocus) {
1128          opts.target.focus();
1129        }
1130      }
1131
1132    }
1133
1134    /**
1135     * Interim-element onShow logic....
1136     */
1137    function onShow(scope, element, opts) {
1138
1139      watchAsyncLoad();
1140      sanitizeAndConfigure(scope, opts);
1141
1142      opts.hideBackdrop = showBackdrop(scope, element, opts);
1143
1144      return showDropDown(scope, element, opts)
1145        .then(function(response) {
1146          element.attr('aria-hidden', 'false');
1147          opts.alreadyOpen = true;
1148          opts.cleanupInteraction = activateInteraction();
1149          opts.cleanupResizing = activateResizing();
1150
1151          return response;
1152        }, opts.hideBackdrop);
1153
1154      // ************************************
1155      // Closure Functions
1156      // ************************************
1157
1158      /**
1159       *  Attach the select DOM element(s) and animate to the correct positions
1160       *  and scalings...
1161       */
1162      function showDropDown(scope, element, opts) {
1163        opts.parent.append(element);
1164
1165        return $q(function(resolve, reject) {
1166
1167          try {
1168
1169            $animateCss(element, {removeClass: 'md-leave', duration: 0})
1170              .start()
1171              .then(positionAndFocusMenu)
1172              .then(resolve);
1173
1174          } catch (e) {
1175            reject(e);
1176          }
1177
1178        });
1179      }
1180
1181      /**
1182       * Initialize container and dropDown menu positions/scale, then animate
1183       * to show... and autoFocus.
1184       */
1185      function positionAndFocusMenu() {
1186        return $q(function(resolve) {
1187          if (opts.isRemoved) return $q.reject(false);
1188
1189          var info = calculateMenuPositions(scope, element, opts);
1190
1191          info.container.element.css(animator.toCss(info.container.styles));
1192          info.dropDown.element.css(animator.toCss(info.dropDown.styles));
1193
1194          $$rAF(function() {
1195            element.addClass('md-active');
1196            info.dropDown.element.css(animator.toCss({transform: ''}));
1197
1198            autoFocus(opts.focusedNode);
1199            resolve();
1200          });
1201
1202        });
1203      }
1204
1205      /**
1206       * Show modal backdrop element...
1207       */
1208      function showBackdrop(scope, element, options) {
1209
1210        // If we are not within a dialog...
1211        if (options.disableParentScroll && !$mdUtil.getClosest(options.target, 'MD-DIALOG')) {
1212          // !! DO this before creating the backdrop; since disableScrollAround()
1213          //    configures the scroll offset; which is used by mdBackDrop postLink()
1214          options.restoreScroll = $mdUtil.disableScrollAround(options.element, options.parent);
1215        } else {
1216          options.disableParentScroll = false;
1217        }
1218
1219        if (options.hasBackdrop) {
1220          // Override duration to immediately show invisible backdrop
1221          options.backdrop = $mdUtil.createBackdrop(scope, "md-select-backdrop md-click-catcher");
1222          $animate.enter(options.backdrop, $document[0].body, null, {duration: 0});
1223        }
1224
1225        /**
1226         * Hide modal backdrop element...
1227         */
1228        return function hideBackdrop() {
1229          if (options.backdrop) options.backdrop.remove();
1230          if (options.disableParentScroll) options.restoreScroll();
1231
1232          delete options.restoreScroll;
1233        };
1234      }
1235
1236      /**
1237       *
1238       */
1239      function autoFocus(focusedNode) {
1240        if (focusedNode && !focusedNode.hasAttribute('disabled')) {
1241          focusedNode.focus();
1242        }
1243      }
1244
1245      /**
1246       * Check for valid opts and set some sane defaults
1247       */
1248      function sanitizeAndConfigure(scope, options) {
1249        var selectEl = element.find('md-select-menu');
1250
1251        if (!options.target) {
1252          throw new Error($mdUtil.supplant(ERROR_TARGET_EXPECTED, [options.target]));
1253        }
1254
1255        angular.extend(options, {
1256          isRemoved: false,
1257          target: angular.element(options.target), //make sure it's not a naked dom node
1258          parent: angular.element(options.parent),
1259          selectEl: selectEl,
1260          contentEl: element.find('md-content'),
1261          optionNodes: selectEl[0].getElementsByTagName('md-option')
1262        });
1263      }
1264
1265      /**
1266       * Configure various resize listeners for screen changes
1267       */
1268      function activateResizing() {
1269        var debouncedOnResize = (function(scope, target, options) {
1270
1271          return function() {
1272            if (options.isRemoved) return;
1273
1274            var updates = calculateMenuPositions(scope, target, options);
1275            var container = updates.container;
1276            var dropDown = updates.dropDown;
1277
1278            container.element.css(animator.toCss(container.styles));
1279            dropDown.element.css(animator.toCss(dropDown.styles));
1280          };
1281
1282        })(scope, element, opts);
1283
1284        var window = angular.element($window);
1285        window.on('resize', debouncedOnResize);
1286        window.on('orientationchange', debouncedOnResize);
1287
1288        // Publish deactivation closure...
1289        return function deactivateResizing() {
1290
1291          // Disable resizing handlers
1292          window.off('resize', debouncedOnResize);
1293          window.off('orientationchange', debouncedOnResize);
1294        };
1295      }
1296
1297      /**
1298       *  If asynchronously loading, watch and update internal
1299       *  '$$loadingAsyncDone' flag
1300       */
1301      function watchAsyncLoad() {
1302        if (opts.loadingAsync && !opts.isRemoved) {
1303          scope.$$loadingAsyncDone = false;
1304
1305          $q.when(opts.loadingAsync)
1306            .then(function() {
1307              scope.$$loadingAsyncDone = true;
1308              delete opts.loadingAsync;
1309            }).then(function() {
1310              $$rAF(positionAndFocusMenu);
1311            });
1312        }
1313      }
1314
1315      /**
1316       *
1317       */
1318      function activateInteraction() {
1319        if (opts.isRemoved) return;
1320
1321        var dropDown = opts.selectEl;
1322        var selectCtrl = dropDown.controller('mdSelectMenu') || {};
1323
1324        element.addClass('md-clickable');
1325
1326        // Close on backdrop click
1327        opts.backdrop && opts.backdrop.on('click', onBackdropClick);
1328
1329        // Escape to close
1330        // Cycling of options, and closing on enter
1331        dropDown.on('keydown', onMenuKeyDown);
1332        dropDown.on('click', checkCloseMenu);
1333
1334        return function cleanupInteraction() {
1335          opts.backdrop && opts.backdrop.off('click', onBackdropClick);
1336          dropDown.off('keydown', onMenuKeyDown);
1337          dropDown.off('click', checkCloseMenu);
1338
1339          element.removeClass('md-clickable');
1340          opts.isRemoved = true;
1341        };
1342
1343        // ************************************
1344        // Closure Functions
1345        // ************************************
1346
1347        function onBackdropClick(e) {
1348          e.preventDefault();
1349          e.stopPropagation();
1350          opts.restoreFocus = false;
1351          $mdUtil.nextTick($mdSelect.hide, true);
1352        }
1353
1354        function onMenuKeyDown(ev) {
1355          ev.preventDefault();
1356          ev.stopPropagation();
1357
1358          switch (ev.keyCode) {
1359            case keyCodes.UP_ARROW:
1360              return focusPrevOption();
1361            case keyCodes.DOWN_ARROW:
1362              return focusNextOption();
1363            case keyCodes.SPACE:
1364            case keyCodes.ENTER:
1365              var option = $mdUtil.getClosest(ev.target, 'md-option');
1366              if (option) {
1367                dropDown.triggerHandler({
1368                  type: 'click',
1369                  target: option
1370                });
1371                ev.preventDefault();
1372              }
1373              checkCloseMenu(ev);
1374              break;
1375            case keyCodes.TAB:
1376            case keyCodes.ESCAPE:
1377              ev.stopPropagation();
1378              ev.preventDefault();
1379              opts.restoreFocus = true;
1380              $mdUtil.nextTick($mdSelect.hide, true);
1381              break;
1382            default:
1383              if ($mdConstant.isInputKey(ev) || $mdConstant.isNumPadKey(ev)) {
1384                var optNode = dropDown.controller('mdSelectMenu').optNodeForKeyboardSearch(ev);
1385                opts.focusedNode = optNode || opts.focusedNode;
1386                optNode && optNode.focus();
1387              }
1388          }
1389        }
1390
1391        function focusOption(direction) {
1392          var optionsArray = $mdUtil.nodesToArray(opts.optionNodes);
1393          var index = optionsArray.indexOf(opts.focusedNode);
1394
1395          var newOption;
1396
1397          do {
1398            if (index === -1) {
1399              // We lost the previously focused element, reset to first option
1400              index = 0;
1401            } else if (direction === 'next' && index < optionsArray.length - 1) {
1402              index++;
1403            } else if (direction === 'prev' && index > 0) {
1404              index--;
1405            }
1406            newOption = optionsArray[index];
1407            if (newOption.hasAttribute('disabled')) newOption = undefined;
1408          } while (!newOption && index < optionsArray.length - 1 && index > 0);
1409
1410          newOption && newOption.focus();
1411          opts.focusedNode = newOption;
1412        }
1413
1414        function focusNextOption() {
1415          focusOption('next');
1416        }
1417
1418        function focusPrevOption() {
1419          focusOption('prev');
1420        }
1421
1422        function checkCloseMenu(ev) {
1423          if (ev && ( ev.type == 'click') && (ev.currentTarget != dropDown[0])) return;
1424          if ( mouseOnScrollbar() ) return;
1425
1426          var option = $mdUtil.getClosest(ev.target, 'md-option');
1427          if (option && option.hasAttribute && !option.hasAttribute('disabled')) {
1428            ev.preventDefault();
1429            ev.stopPropagation();
1430            if (!selectCtrl.isMultiple) {
1431              opts.restoreFocus = true;
1432
1433              $mdUtil.nextTick(function () {
1434                $mdSelect.hide(selectCtrl.ngModel.$viewValue);
1435              }, true);
1436            }
1437          }
1438          /**
1439           * check if the mouseup event was on a scrollbar
1440           */
1441          function mouseOnScrollbar() {
1442            var clickOnScrollbar = false;
1443            if (ev && (ev.currentTarget.children.length > 0)) {
1444              var child = ev.currentTarget.children[0];
1445              var hasScrollbar = child.scrollHeight > child.clientHeight;
1446              if (hasScrollbar && child.children.length > 0) {
1447                var relPosX = ev.pageX - ev.currentTarget.getBoundingClientRect().left;
1448                if (relPosX > child.querySelector('md-option').offsetWidth)
1449                  clickOnScrollbar = true;
1450              }
1451            }
1452            return clickOnScrollbar;
1453          }
1454        }
1455      }
1456
1457    }
1458
1459    /**
1460     * To notify listeners that the Select menu has closed,
1461     * trigger the [optional] user-defined expression
1462     */
1463    function announceClosed(opts) {
1464      var mdSelect = opts.selectCtrl;
1465      if (mdSelect) {
1466        var menuController = opts.selectEl.controller('mdSelectMenu');
1467        mdSelect.setLabelText(menuController ? menuController.selectedLabels() : '');
1468        mdSelect.triggerClose();
1469      }
1470    }
1471
1472
1473    /**
1474     * Calculate the
1475     */
1476    function calculateMenuPositions(scope, element, opts) {
1477      var
1478        containerNode = element[0],
1479        targetNode = opts.target[0].children[0], // target the label
1480        parentNode = $document[0].body,
1481        selectNode = opts.selectEl[0],
1482        contentNode = opts.contentEl[0],
1483        parentRect = parentNode.getBoundingClientRect(),
1484        targetRect = targetNode.getBoundingClientRect(),
1485        shouldOpenAroundTarget = false,
1486        bounds = {
1487          left: parentRect.left + SELECT_EDGE_MARGIN,
1488          top: SELECT_EDGE_MARGIN,
1489          bottom: parentRect.height - SELECT_EDGE_MARGIN,
1490          right: parentRect.width - SELECT_EDGE_MARGIN - ($mdUtil.floatingScrollbars() ? 16 : 0)
1491        },
1492        spaceAvailable = {
1493          top: targetRect.top - bounds.top,
1494          left: targetRect.left - bounds.left,
1495          right: bounds.right - (targetRect.left + targetRect.width),
1496          bottom: bounds.bottom - (targetRect.top + targetRect.height)
1497        },
1498        maxWidth = parentRect.width - SELECT_EDGE_MARGIN * 2,
1499        selectedNode = selectNode.querySelector('md-option[selected]'),
1500        optionNodes = selectNode.getElementsByTagName('md-option'),
1501        optgroupNodes = selectNode.getElementsByTagName('md-optgroup'),
1502        isScrollable = calculateScrollable(element, contentNode),
1503        centeredNode;
1504
1505      var loading = isPromiseLike(opts.loadingAsync);
1506      if (!loading) {
1507        // If a selected node, center around that
1508        if (selectedNode) {
1509          centeredNode = selectedNode;
1510          // If there are option groups, center around the first option group
1511        } else if (optgroupNodes.length) {
1512          centeredNode = optgroupNodes[0];
1513          // Otherwise - if we are not loading async - center around the first optionNode
1514        } else if (optionNodes.length) {
1515          centeredNode = optionNodes[0];
1516          // In case there are no options, center on whatever's in there... (eg progress indicator)
1517        } else {
1518          centeredNode = contentNode.firstElementChild || contentNode;
1519        }
1520      } else {
1521        // If loading, center on progress indicator
1522        centeredNode = contentNode.firstElementChild || contentNode;
1523      }
1524
1525      if (contentNode.offsetWidth > maxWidth) {
1526        contentNode.style['max-width'] = maxWidth + 'px';
1527      } else {
1528        contentNode.style.maxWidth = null;
1529      }
1530      if (shouldOpenAroundTarget) {
1531        contentNode.style['min-width'] = targetRect.width + 'px';
1532      }
1533
1534      // Remove padding before we compute the position of the menu
1535      if (isScrollable) {
1536        selectNode.classList.add('md-overflow');
1537      }
1538
1539      var focusedNode = centeredNode;
1540      if ((focusedNode.tagName || '').toUpperCase() === 'MD-OPTGROUP') {
1541        focusedNode = optionNodes[0] || contentNode.firstElementChild || contentNode;
1542        centeredNode = focusedNode;
1543      }
1544      // Cache for autoFocus()
1545      opts.focusedNode = focusedNode;
1546
1547      // Get the selectMenuRect *after* max-width is possibly set above
1548      containerNode.style.display = 'block';
1549      var selectMenuRect = selectNode.getBoundingClientRect();
1550      var centeredRect = getOffsetRect(centeredNode);
1551
1552      if (centeredNode) {
1553        var centeredStyle = $window.getComputedStyle(centeredNode);
1554        centeredRect.paddingLeft = parseInt(centeredStyle.paddingLeft, 10) || 0;
1555        centeredRect.paddingRight = parseInt(centeredStyle.paddingRight, 10) || 0;
1556      }
1557
1558      if (isScrollable) {
1559        var scrollBuffer = contentNode.offsetHeight / 2;
1560        contentNode.scrollTop = centeredRect.top + centeredRect.height / 2 - scrollBuffer;
1561
1562        if (spaceAvailable.top < scrollBuffer) {
1563          contentNode.scrollTop = Math.min(
1564            centeredRect.top,
1565            contentNode.scrollTop + scrollBuffer - spaceAvailable.top
1566          );
1567        } else if (spaceAvailable.bottom < scrollBuffer) {
1568          contentNode.scrollTop = Math.max(
1569            centeredRect.top + centeredRect.height - selectMenuRect.height,
1570            contentNode.scrollTop - scrollBuffer + spaceAvailable.bottom
1571          );
1572        }
1573      }
1574
1575      var left, top, transformOrigin, minWidth, fontSize;
1576      if (shouldOpenAroundTarget) {
1577        left = targetRect.left;
1578        top = targetRect.top + targetRect.height;
1579        transformOrigin = '50% 0';
1580        if (top + selectMenuRect.height > bounds.bottom) {
1581          top = targetRect.top - selectMenuRect.height;
1582          transformOrigin = '50% 100%';
1583        }
1584      } else {
1585        left = (targetRect.left + centeredRect.left - centeredRect.paddingLeft) + 2;
1586        top = Math.floor(targetRect.top + targetRect.height / 2 - centeredRect.height / 2 -
1587            centeredRect.top + contentNode.scrollTop) + 2;
1588
1589        transformOrigin = (centeredRect.left + targetRect.width / 2) + 'px ' +
1590          (centeredRect.top + centeredRect.height / 2 - contentNode.scrollTop) + 'px 0px';
1591
1592        minWidth = Math.min(targetRect.width + centeredRect.paddingLeft + centeredRect.paddingRight, maxWidth);
1593
1594        fontSize = window.getComputedStyle(targetNode)['font-size'];
1595      }
1596
1597      // Keep left and top within the window
1598      var containerRect = containerNode.getBoundingClientRect();
1599      var scaleX = Math.round(100 * Math.min(targetRect.width / selectMenuRect.width, 1.0)) / 100;
1600      var scaleY = Math.round(100 * Math.min(targetRect.height / selectMenuRect.height, 1.0)) / 100;
1601
1602      return {
1603        container: {
1604          element: angular.element(containerNode),
1605          styles: {
1606            left: Math.floor(clamp(bounds.left, left, bounds.right - containerRect.width)),
1607            top: Math.floor(clamp(bounds.top, top, bounds.bottom - containerRect.height)),
1608            'min-width': minWidth,
1609            'font-size': fontSize
1610          }
1611        },
1612        dropDown: {
1613          element: angular.element(selectNode),
1614          styles: {
1615            transformOrigin: transformOrigin,
1616            transform: !opts.alreadyOpen ? $mdUtil.supplant('scale({0},{1})', [scaleX, scaleY]) : ""
1617          }
1618        }
1619      };
1620
1621    }
1622
1623  }
1624
1625  function isPromiseLike(obj) {
1626    return obj && angular.isFunction(obj.then);
1627  }
1628
1629  function clamp(min, n, max) {
1630    return Math.max(min, Math.min(n, max));
1631  }
1632
1633  function getOffsetRect(node) {
1634    return node ? {
1635      left: node.offsetLeft,
1636      top: node.offsetTop,
1637      width: node.offsetWidth,
1638      height: node.offsetHeight
1639    } : {left: 0, top: 0, width: 0, height: 0};
1640  }
1641
1642  function calculateScrollable(element, contentNode) {
1643    var isScrollable = false;
1644
1645    try {
1646      var oldDisplay = element[0].style.display;
1647
1648      // Set the element's display to block so that this calculation is correct
1649      element[0].style.display = 'block';
1650
1651      isScrollable = contentNode.scrollHeight > contentNode.offsetHeight;
1652
1653      // Reset it back afterwards
1654      element[0].style.display = oldDisplay;
1655    } finally {
1656      // Nothing to do
1657    }
1658    return isScrollable;
1659  }
1660}
1661
1662ngmaterial.components.select = angular.module("material.components.select");