From 727b1c105687892cf85e955a05bf09e266a1ec86 Mon Sep 17 00:00:00 2001 From: Ed Pelc Date: Mon, 4 May 2015 16:10:06 -0400 Subject: [PATCH] feat(autocomplete): Add promise support to md-item-text --- .../autocomplete/js/autocompleteController.js | 384 +++++++++++++----- .../autocomplete/js/autocompleteDirective.js | 230 +++++++---- 2 files changed, 421 insertions(+), 193 deletions(-) diff --git a/src/components/autocomplete/js/autocompleteController.js b/src/components/autocomplete/js/autocompleteController.js index 8fc6c442066..160d69cc317 100644 --- a/src/components/autocomplete/js/autocompleteController.js +++ b/src/components/autocomplete/js/autocompleteController.js @@ -6,11 +6,10 @@ var ITEM_HEIGHT = 41, MAX_HEIGHT = 5.5 * ITEM_HEIGHT, MENU_PADDING = 8; -function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $timeout, $mdTheming, $window, $animate, $rootElement, $q) { - +function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $timeout, $mdTheming, $window, + $animate, $rootElement, $attrs, $q) { //-- private variables - - var self = this, + var ctrl = this, itemParts = $scope.itemsExpr.split(/ in /i), itemExpr = itemParts[1], elements = null, @@ -18,43 +17,48 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $timeout, $ cache = {}, noBlur = false, selectedItemWatchers = [], - hasFocus = false; + hasFocus = false, + lastCount = 0; - //-- public variables + //-- public variables with handlers + defineProperty('hidden', handleHiddenChange, true); - self.scope = $scope; - self.parent = $scope.$parent; - self.itemName = itemParts[0]; - self.matches = []; - self.loading = false; - self.hidden = true; - self.index = null; - self.messages = []; - self.id = $mdUtil.nextUid(); + //-- public variables + ctrl.scope = $scope; + ctrl.parent = $scope.$parent; + ctrl.itemName = itemParts[0]; + ctrl.matches = []; + ctrl.loading = false; + ctrl.hidden = true; + ctrl.index = null; + ctrl.messages = []; + ctrl.id = $mdUtil.nextUid(); + ctrl.isDisabled = null; + ctrl.isRequired = null; //-- public methods - - self.keydown = keydown; - self.blur = blur; - self.focus = focus; - self.clear = clearValue; - self.select = select; - self.getCurrentDisplayValue = getCurrentDisplayValue; - self.registerSelectedItemWatcher = registerSelectedItemWatcher; - self.unregisterSelectedItemWatcher = unregisterSelectedItemWatcher; - - self.listEnter = function () { noBlur = true; }; - self.listLeave = function () { - noBlur = false; - if (!hasFocus) self.hidden = true; - }; - self.mouseUp = function () { elements.input.focus(); }; + ctrl.keydown = keydown; + ctrl.blur = blur; + ctrl.focus = focus; + ctrl.clear = clearValue; + ctrl.select = select; + ctrl.listEnter = onListEnter; + ctrl.listLeave = onListLeave; + ctrl.mouseUp = onMouseup; + ctrl.getCurrentDisplayValue = getCurrentDisplayValue; + ctrl.registerSelectedItemWatcher = registerSelectedItemWatcher; + ctrl.unregisterSelectedItemWatcher = unregisterSelectedItemWatcher; return init(); //-- initialization methods + /** + * Initialize the controller, setup watchers, gather elements + */ function init () { + $mdUtil.initOptionalProperties($scope, $attrs, { searchText: null, selectedItem: null } ); + $mdTheming($element); configureWatchers(); $timeout(function () { gatherElements(); @@ -63,6 +67,10 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $timeout, $ }); } + /** + * Calculates the dropdown's position and applies the new styles to the menu element + * @returns {*} + */ function positionDropdown () { if (!elements) return $timeout(positionDropdown, 0, false); var hrect = elements.wrap.getBoundingClientRect(), @@ -89,6 +97,9 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $timeout, $ elements.$.ul.css(styles); $timeout(correctHorizontalAlignment, 0, false); + /** + * Makes sure that the menu doesn't go off of the screen on either side. + */ function correctHorizontalAlignment () { var dropdown = elements.ul.getBoundingClientRect(), styles = {}; @@ -99,6 +110,9 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $timeout, $ } } + /** + * Moves the dropdown menu to the body tag in order to avoid z-index and overflow issues. + */ function moveDropdown () { if (!elements.$.root.length) return; $mdTheming(elements.$.ul); @@ -107,23 +121,38 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $timeout, $ if ($animate.pin) $animate.pin(elements.$.ul, $rootElement); } + /** + * Sends focus to the input element. + */ function focusElement () { if ($scope.autofocus) elements.input.focus(); } + /** + * Sets up any watchers used by autocomplete + */ function configureWatchers () { var wait = parseInt($scope.delay, 10) || 0; - $scope.$watch('searchText', wait - ? $mdUtil.debounce(handleSearchText, wait) - : handleSearchText); + $attrs.$observe('disabled', function (value) { ctrl.isDisabled = value; }); + $attrs.$observe('required', function (value) { ctrl.isRequired = value !== null; }); + $scope.$watch('searchText', wait ? $mdUtil.debounce(handleSearchText, wait) : handleSearchText); registerSelectedItemWatcher(selectedItemChange); $scope.$watch('selectedItem', handleSelectedItemChange); - $scope.$watch('$mdAutocompleteCtrl.hidden', function (hidden, oldHidden) { - if (!hidden && oldHidden) positionDropdown(); - }); angular.element($window).on('resize', positionDropdown); + $scope.$on('$destroy', cleanup); } + /** + * Removes any events or leftover elements created by this controller + */ + function cleanup () { + angular.element($window).off('resize', positionDropdown); + elements.$.ul.remove(); + } + + /** + * Gathers all of the elements needed for this controller + */ function gatherElements () { elements = { main: $element[0], @@ -137,6 +166,10 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $timeout, $ elements.$ = getAngularElements(elements); } + /** + * Finds the element that the menu will base its position on + * @returns {*} + */ function getSnapTarget () { for (var element = $element; element.length; element = element.parent()) { if (angular.isDefined(element.attr('md-autocomplete-snap'))) return element[0]; @@ -144,6 +177,11 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $timeout, $ return elements.wrap; } + /** + * Gathers angular-wrapped versions of each element + * @param elements + * @returns {{}} + */ function getAngularElements (elements) { var obj = {}; for (var key in elements) { @@ -154,16 +192,63 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $timeout, $ //-- event/change handlers + /** + * Handles changes to the `hidden` property. + * @param hidden + * @param oldHidden + */ + function handleHiddenChange (hidden, oldHidden) { + if (!hidden && oldHidden) { + positionDropdown(); + if (elements) $timeout(function () { $mdUtil.disableScrollAround(elements.ul); }, 0, false); + } else if (hidden && !oldHidden) { + $mdUtil.enableScrolling(); + } + } + + /** + * When the user mouses over the dropdown menu, ignore blur events. + */ + function onListEnter () { + noBlur = true; + } + + /** + * When the user's mouse leaves the menu, blur events may hide the menu again. + */ + function onListLeave () { + noBlur = false; + if (!hasFocus) ctrl.hidden = true; + } + + /** + * When the mouse button is released, send focus back to the input field. + */ + function onMouseup () { + elements.input.focus(); + } + + /** + * Handles changes to the selected item. + * @param selectedItem + * @param previousSelectedItem + */ function selectedItemChange (selectedItem, previousSelectedItem) { if (selectedItem) { - getDisplayValue(selectedItem).then(function(val){ + getDisplayValue(selectedItem).then(function(val) { $scope.searchText = val; }); } - if ($scope.itemChange && selectedItem !== previousSelectedItem) + if (angular.isFunction($scope.itemChange) && selectedItem !== previousSelectedItem) $scope.itemChange(getItemScope(selectedItem)); } + /** + * Calls any external watchers listening for the selected item. Used in conjunction with + * `registerSelectedItemWatcher`. + * @param selectedItem + * @param previousSelectedItem + */ function handleSelectedItemChange(selectedItem, previousSelectedItem) { for (var i = 0; i < selectedItemWatchers.length; ++i) { selectedItemWatchers[i](selectedItem, previousSelectedItem); @@ -191,70 +276,87 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $timeout, $ } } + /** + * Handles changes to the searchText property. + * @param searchText + * @param previousSearchText + */ function handleSearchText (searchText, previousSearchText) { - self.index = getDefaultIndex(); + ctrl.index = getDefaultIndex(); //-- do nothing on init if (searchText === previousSearchText) return; - //-- clear selected item if search text no longer matches it + getDisplayValue($scope.selectedItem).then(function(val) { + //-- clear selected item if search text no longer matches it if (searchText !== val) $scope.selectedItem = null; - else return; + else return; //-- trigger change event if available - if ($scope.textChange && searchText !== previousSearchText) + if (angular.isFunction($scope.textChange) && searchText !== previousSearchText) $scope.textChange(getItemScope($scope.selectedItem)); //-- cancel results if search text is not long enough if (!isMinLengthMet()) { - self.loading = false; - self.matches = []; - self.hidden = shouldHide(); + ctrl.loading = false; + ctrl.matches = []; + ctrl.hidden = shouldHide(); updateMessages(); } else { handleQuery(); } }); + } + /** + * Handles input blur event, determines if the dropdown should hide. + */ function blur () { hasFocus = false; - if (!noBlur) self.hidden = true; + if (!noBlur) ctrl.hidden = true; } + /** + * Handles input focus event, determines if the dropdown should show. + */ function focus () { hasFocus = true; //-- if searchText is null, let's force it to be a string if (!angular.isString($scope.searchText)) $scope.searchText = ''; if ($scope.minLength > 0) return; - self.hidden = shouldHide(); - if (!self.hidden) handleQuery(); + ctrl.hidden = shouldHide(); + if (!ctrl.hidden) handleQuery(); } + /** + * Handles keyboard input. + * @param event + */ function keydown (event) { switch (event.keyCode) { case $mdConstant.KEY_CODE.DOWN_ARROW: - if (self.loading) return; + if (ctrl.loading) return; event.preventDefault(); - self.index = Math.min(self.index + 1, self.matches.length - 1); + ctrl.index = Math.min(ctrl.index + 1, ctrl.matches.length - 1); updateScroll(); - updateSelectionMessage(); + updateMessages(); break; case $mdConstant.KEY_CODE.UP_ARROW: - if (self.loading) return; + if (ctrl.loading) return; event.preventDefault(); - self.index = self.index < 0 ? self.matches.length - 1 : Math.max(0, self.index - 1); + ctrl.index = ctrl.index < 0 ? ctrl.matches.length - 1 : Math.max(0, ctrl.index - 1); updateScroll(); - updateSelectionMessage(); + updateMessages(); break; case $mdConstant.KEY_CODE.TAB: case $mdConstant.KEY_CODE.ENTER: - if (self.hidden || self.loading || self.index < 0 || self.matches.length < 1) return; + if (ctrl.hidden || ctrl.loading || ctrl.index < 0 || ctrl.matches.length < 1) return; event.preventDefault(); - select(self.index); + select(ctrl.index); break; case $mdConstant.KEY_CODE.ESCAPE: - self.matches = []; - self.hidden = true; - self.index = getDefaultIndex(); + ctrl.matches = []; + ctrl.hidden = true; + ctrl.index = getDefaultIndex(); break; default: } @@ -262,109 +364,179 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $timeout, $ //-- getters + /** + * Returns the minimum length needed to display the dropdown. + * @returns {*} + */ function getMinLength () { return angular.isNumber($scope.minLength) ? $scope.minLength : 1; } + /** + * Returns the display value for an item. + * @param item + * @returns {*} + */ function getDisplayValue (item) { return (item && $scope.itemText) ? $q.when($scope.itemText(getItemScope(item))) : $q.when(item); } + /** + * Returns the locals object for compiling item templates. + * @param item + * @returns {{}} + */ function getItemScope (item) { if (!item) return; var locals = {}; - if (self.itemName) locals[self.itemName] = item; + if (ctrl.itemName) locals[ctrl.itemName] = item; return locals; } + /** + * Returns the default index based on whether or not autoselect is enabled. + * @returns {number} + */ function getDefaultIndex () { return $scope.autoselect ? 0 : -1; } + /** + * Determines if the menu should be hidden. + * @returns {boolean} + */ function shouldHide () { if (!isMinLengthMet()) return true; } + /** + * Returns the display value of the current item. + * @returns {*} + */ function getCurrentDisplayValue () { - return getDisplayValue(self.matches[self.index]); + return getDisplayValue(ctrl.matches[ctrl.index]); } + /** + * Determines if the minimum length is met by the search text. + * @returns {*} + */ function isMinLengthMet () { - return $scope.searchText.length >= getMinLength(); + return angular.isDefined($scope.searchText) && $scope.searchText.length >= getMinLength(); } //-- actions - function select (index) { - return getDisplayValue(self.matches[index]).then(function(val){ - $scope.selectedItem = self.matches[index]; - $scope.searchText = val; - }, function() { - $scope.selectedItem = null; - $scope.searchText = ''; - }) - .finally(function() { - // Hide the dropdown and clear the matches - self.hidden = true; - self.index = 0; - self.matches = []; + /** + * Defines a public property with a handler and a default value. + * @param key + * @param handler + * @param value + */ + function defineProperty (key, handler, value) { + Object.defineProperty(ctrl, key, { + get: function () { return value; }, + set: function (newValue) { + var oldValue = value; + value = newValue; + handler(newValue, oldValue); + } }); } + /** + * Selects the item at the given index. + * @param index + */ + function select(index) { + //-- force form to update state for validation + $timeout(function() { + getDisplayValue(ctrl.matches[index]).then(function(val) { + var ngModel = elements.$.input.controller('ngModel'); + ngModel.$setViewValue(val); + ngModel.$render(); + }).finally(function() { + $scope.selectedItem = ctrl.matches[index]; + ctrl.loading = false; + ctrl.hidden = true; + ctrl.index = 0; + ctrl.matches = []; + }); + }); + } + + + /** + * Clears the searchText value and selected item. + */ function clearValue () { $scope.searchText = ''; - return select(-1).then(function(){ - // Per http://www.w3schools.com/jsref/event_oninput.asp - var eventObj = document.createEvent('CustomEvent'); - eventObj.initCustomEvent('input', true, true, {value: $scope.searchText}); - elements.input.dispatchEvent(eventObj); + select(-1); + + // Per http://www.w3schools.com/jsref/event_oninput.asp + var eventObj = document.createEvent('CustomEvent'); + eventObj.initCustomEvent('input', true, true, {value: $scope.searchText}); + elements.input.dispatchEvent(eventObj); - elements.input.focus(); - }); + elements.input.focus(); } + /** + * Fetches the results for the provided search text. + * @param searchText + */ function fetchResults (searchText) { var items = $scope.$parent.$eval(itemExpr), term = searchText.toLowerCase(); if (angular.isArray(items)) { handleResults(items); - } else { - self.loading = true; + } else if (items) { + ctrl.loading = true; if (items.success) items.success(handleResults); if (items.then) items.then(handleResults); - if (items.error) items.error(function () { self.loading = false; }); + if (items.error) items.error(function () { ctrl.loading = false; }); } function handleResults (matches) { cache[term] = matches; - self.loading = false; if (searchText !== $scope.searchText) return; //-- just cache the results if old request + ctrl.loading = false; promise = null; - self.matches = matches; - self.hidden = shouldHide(); + ctrl.matches = matches; + ctrl.hidden = shouldHide(); updateMessages(); positionDropdown(); } } + /** + * Updates the ARIA messages + */ function updateMessages () { - if (self.hidden) return; - switch (self.matches.length) { - case 0: return self.messages.splice(0); - case 1: return self.messages.push({ display: 'There is 1 match available.' }); - default: return self.messages.push({ display: 'There are ' - + self.matches.length - + ' matches available.' }); - } + getCurrentDisplayValue().then(function(msg) { + ctrl.messages = [ getCountMessage(), msg ]; + }); } - function updateSelectionMessage () { - getCurrentDisplayValue().then(function(msg){ - self.messages.push({ display: msg}); - }); + /** + * Returns the ARIA message for how many results match the current query. + * @returns {*} + */ + function getCountMessage () { + if (lastCount === ctrl.matches.length) return ''; + lastCount = ctrl.matches.length; + switch (ctrl.matches.length) { + case 0: return 'There are no matches available.'; + case 1: return 'There is 1 match available.'; + default: return 'There are ' + ctrl.matches.length + ' matches available.'; + } } + /** + * Makes sure that the focused element is within view. + */ function updateScroll () { - var li = elements.li[self.index], + if (!elements.li[ctrl.index]) return; + var li = elements.li[ctrl.index], top = li.offsetTop, bot = top + li.offsetHeight, hgt = elements.ul.clientHeight; @@ -375,6 +547,10 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $timeout, $ } } + /** + * Starts the query to gather the results for the current searchText. Attempts to return cached + * results first, then forwards the process to `fetchResults` if necessary. + */ function handleQuery () { var searchText = $scope.searchText, term = searchText.toLowerCase(); @@ -385,12 +561,12 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $timeout, $ } //-- if results are cached, pull in cached results if (!$scope.noCache && cache[term]) { - self.matches = cache[term]; + ctrl.matches = cache[term]; updateMessages(); } else { fetchResults(searchText); } - self.hidden = shouldHide(); + if (hasFocus) ctrl.hidden = shouldHide(); } } diff --git a/src/components/autocomplete/js/autocompleteDirective.js b/src/components/autocomplete/js/autocompleteDirective.js index cf694321a92..e4bd170249c 100644 --- a/src/components/autocomplete/js/autocompleteDirective.js +++ b/src/components/autocomplete/js/autocompleteDirective.js @@ -17,10 +17,23 @@ angular * In more complex cases, you may want to include other content such as a message to display when * no matches were found. You can do this by wrapping your template in `md-item-template` and adding * a tag for `md-not-found`. An example of this is shown below. + * ### Validation + * + * You can use `ng-messages` to include validation the same way that you would normally validate; + * however, if you want to replicate a standard input with a floating label, you will have to do the + * following: + * + * - Make sure that your template is wrapped in `md-item-template` + * - Add your `ng-messages` code inside of `md-autocomplete` + * - Add your validation properties to `md-autocomplete` (ie. `required`) + * - Add a `name` to `md-autocomplete` (to be used on the generated `input`) + * + * There is an example below of how this should look. + * * * @param {expression} md-items An expression in the format of `item in items` to iterate over matches for your search. - * @param {expression} md-selected-item-change An expression to be run each time a new item is selected - * @param {expression} md-search-text-change An expression to be run each time the search text updates + * @param {expression=} md-selected-item-change An expression to be run each time a new item is selected + * @param {expression=} md-search-text-change An expression to be run each time the search text updates * @param {string=} md-search-text A model to bind the search query text to * @param {object=} md-selected-item A model to bind the selected item to * @param {string=} md-item-text An expression that will convert your object to a single string. @@ -32,6 +45,7 @@ angular * @param {boolean=} md-autofocus If true, will immediately focus the input element * @param {boolean=} md-autoselect If true, the first item will be selected by default * @param {string=} md-menu-class This will be applied to the dropdown menu for styling + * @param {string=} md-floating-label This will add a floating label to autocomplete and wrap it in `md-input-container` * * @usage * ###Basic Example @@ -63,127 +77,165 @@ angular * * In this example, our code utilizes `md-item-template` and `md-not-found` to specify the different * parts that make up our component. + * + * ### Example with validation + * + *
+ * + * + * {{item.display}} + * + *
+ *
This field is required
+ *
+ *
+ *
+ *
+ * + * In this example, our code utilizes `md-item-template` and `md-not-found` to specify the different + * parts that make up our component. */ function MdAutocomplete ($mdTheming, $mdUtil) { return { controller: 'MdAutocompleteCtrl', controllerAs: '$mdAutocompleteCtrl', - link: link, scope: { - name: '@', - searchText: '=?mdSearchText', - selectedItem: '=?mdSelectedItem', - itemsExpr: '@mdItems', - itemText: '&mdItemText', - placeholder: '@placeholder', - noCache: '=?mdNoCache', - itemChange: '&?mdSelectedItemChange', - textChange: '&?mdSearchTextChange', - minLength: '=?mdMinLength', - delay: '=?mdDelay', - autofocus: '=?mdAutofocus', - floatingLabel: '@?mdFloatingLabel', - autoselect: '=?mdAutoselect', - menuClass: '@?mdMenuClass' + inputName: '@mdInputName', + inputMinlength: '@mdInputMinlength', + inputMaxlength: '@mdInputMaxlength', + searchText: '=?mdSearchText', + selectedItem: '=?mdSelectedItem', + itemsExpr: '@mdItems', + itemText: '&mdItemText', + placeholder: '@placeholder', + noCache: '=?mdNoCache', + itemChange: '&?mdSelectedItemChange', + textChange: '&?mdSearchTextChange', + minLength: '=?mdMinLength', + delay: '=?mdDelay', + autofocus: '=?mdAutofocus', + floatingLabel: '@?mdFloatingLabel', + autoselect: '=?mdAutoselect', + menuClass: '@?mdMenuClass' }, template: function (element, attr) { - var itemTemplate = getItemTemplate(), - noItemsTemplate = getNoItemsTemplate(); + var noItemsTemplate = getNoItemsTemplate(), + itemTemplate = getItemTemplate(), + leftover = element.html(); return '\ - \ - \ - \ - \ - \ - \ - \ - \ - Clear\ - \ + \ + ' + getInputElement() + '\ \ \ \ \ -

{{message.display}}

\ +

{{message}}

\ '; - function getItemTemplate () { - var templateTag = element.find('md-item-template').remove(); - return templateTag.length ? templateTag.html() : element.html(); + function getItemTemplate() { + var templateTag = element.find('md-item-template').remove(), + html = templateTag.length ? templateTag.html() : element.html(); + if (!templateTag.length) element.empty(); + return html; } - function getNoItemsTemplate () { - var templateTag = element.find('md-not-found').remove(); - return templateTag.length ? templateTag.html() : ''; + function getNoItemsTemplate() { + var templateTag = element.find('md-not-found').remove(), + template = templateTag.length ? templateTag.html() : ''; + return template + ? '
  • ' + template + '
  • ' + : ''; + + } + + function getInputElement() { + if (attr.mdFloatingLabel) { + return '\ + \ + \ + \ +
    ' + leftover + '
    \ +
    '; + } else { + return '\ + \ + \ + \ + Clear\ + \ + '; + } } } }; - - function link (scope, element, attr) { - attr.$observe('disabled', function (value) { scope.isDisabled = value; }); - - $mdUtil.initOptionalProperties(scope, attr, {searchText:null, selectedItem:null} ); - - $mdTheming(element); - } }