From 9f815a271eeb318a9d56f42ecaaf1b2ad6ca77bf Mon Sep 17 00:00:00 2001 From: Topher Fangio Date: Thu, 8 Oct 2015 11:37:00 -0500 Subject: [PATCH] feat(chips): Support multiple return values for md-on-append. The usage of `md-on-append` is not well documented and the behavior is not consistent. Fix by updating documentation to set expectations of return values and updating code to conform to their associated behavior. Additionally, this adds support for simlultaneously using an autocomplete selection along with the ability to create new chips. Previously, the chips directive would not allow for a scenario which used the autocomplete to provide a list of options, but also provided a method of inputting new options. The most common case for this was a tag system which showed existing tags, but allowed you to create new ones. Update autocomplete and chips to provide both scenarios and document how this can be achieved. Lastly, workaround a few display issues with contact chips demo (#4450). _**Note:** This work supercedes PR #3816 which can be closed when this is merged._ Fixes #4666. Fixes #4193. Fixes #4412. Fixes #4863. --- .../demoInsideDialog/dialog.tmpl.html | 37 ++++++ .../autocomplete/demoInsideDialog/index.html | 9 ++ .../autocomplete/demoInsideDialog/script.js | 84 +++++++++++++ .../autocomplete/js/autocompleteController.js | 2 +- src/components/chips/chips.spec.js | 112 ++++++++++++++++++ .../chips/demoContactChips/style.scss | 7 ++ .../chips/demoCustomInputs/index.html | 8 +- .../chips/demoCustomInputs/script.js | 15 +++ src/components/chips/js/chipsController.js | 52 ++++---- src/components/chips/js/chipsDirective.js | 9 +- 10 files changed, 310 insertions(+), 25 deletions(-) create mode 100644 src/components/autocomplete/demoInsideDialog/dialog.tmpl.html create mode 100644 src/components/autocomplete/demoInsideDialog/index.html create mode 100644 src/components/autocomplete/demoInsideDialog/script.js diff --git a/src/components/autocomplete/demoInsideDialog/dialog.tmpl.html b/src/components/autocomplete/demoInsideDialog/dialog.tmpl.html new file mode 100644 index 00000000000..ea0012411d1 --- /dev/null +++ b/src/components/autocomplete/demoInsideDialog/dialog.tmpl.html @@ -0,0 +1,37 @@ + + +
+

Autocomplete Dialog Example

+ + + + +
+
+ + +
+
+

Use md-autocomplete to search for matches from local or remote data sources.

+ + + {{item.display}} + + + No states matching "{{ctrl.searchText}}" were found. + + +
+
+
+ +
+ Finished +
+
\ No newline at end of file diff --git a/src/components/autocomplete/demoInsideDialog/index.html b/src/components/autocomplete/demoInsideDialog/index.html new file mode 100644 index 00000000000..6e292869183 --- /dev/null +++ b/src/components/autocomplete/demoInsideDialog/index.html @@ -0,0 +1,9 @@ +
+ +

+ Click the button below to open the dialog with an autocomplete. +

+ + Open Dialog +
+
diff --git a/src/components/autocomplete/demoInsideDialog/script.js b/src/components/autocomplete/demoInsideDialog/script.js new file mode 100644 index 00000000000..d794b650a2a --- /dev/null +++ b/src/components/autocomplete/demoInsideDialog/script.js @@ -0,0 +1,84 @@ +(function () { + 'use strict'; + angular + .module('autocompleteDemoInsideDialog', ['ngMaterial']) + .controller('DemoCtrl', DemoCtrl); + + function DemoCtrl($mdDialog) { + var self = this; + + self.openDialog = function($event) { + $mdDialog.show({ + controller: DialogCtrl, + controllerAs: 'ctrl', + templateUrl: 'dialog.tmpl.html', + parent: angular.element(document.body), + targetEvent: $event, + clickOutsideToClose:true + }) + } + } + + function DialogCtrl ($timeout, $q, $scope, $mdDialog) { + var self = this; + + // list of `state` value/display objects + self.states = loadAll(); + self.querySearch = querySearch; + + // ****************************** + // Template methods + // ****************************** + + self.cancel = function($event) { + $mdDialog.cancel(); + }; + self.finish = function($event) { + $mdDialog.hide(); + }; + + // ****************************** + // Internal methods + // ****************************** + + /** + * Search for states... use $timeout to simulate + * remote dataservice call. + */ + function querySearch (query) { + return query ? self.states.filter( createFilterFor(query) ) : self.states; + } + + /** + * Build `states` list of key/value pairs + */ + function loadAll() { + var allStates = 'Alabama, Alaska, Arizona, Arkansas, California, Colorado, Connecticut, Delaware,\ + Florida, Georgia, Hawaii, Idaho, Illinois, Indiana, Iowa, Kansas, Kentucky, Louisiana,\ + Maine, Maryland, Massachusetts, Michigan, Minnesota, Mississippi, Missouri, Montana,\ + Nebraska, Nevada, New Hampshire, New Jersey, New Mexico, New York, North Carolina,\ + North Dakota, Ohio, Oklahoma, Oregon, Pennsylvania, Rhode Island, South Carolina,\ + South Dakota, Tennessee, Texas, Utah, Vermont, Virginia, Washington, West Virginia,\ + Wisconsin, Wyoming'; + + return allStates.split(/, +/g).map( function (state) { + return { + value: state.toLowerCase(), + display: state + }; + }); + } + + /** + * Create filter function for a query string + */ + function createFilterFor(query) { + var lowercaseQuery = angular.lowercase(query); + + return function filterFn(state) { + return (state.value.indexOf(lowercaseQuery) === 0); + }; + + } + } +})(); diff --git a/src/components/autocomplete/js/autocompleteController.js b/src/components/autocomplete/js/autocompleteController.js index d1c81928684..bca0c4c47ab 100644 --- a/src/components/autocomplete/js/autocompleteController.js +++ b/src/components/autocomplete/js/autocompleteController.js @@ -412,9 +412,9 @@ function MdAutocompleteCtrl ($scope, $element, $mdUtil, $mdConstant, $mdTheming, select(ctrl.index); break; case $mdConstant.KEY_CODE.ENTER: + if (ctrl.hidden || ctrl.loading || ctrl.index < 0 || ctrl.matches.length < 1) return; event.stopPropagation(); event.preventDefault(); - if (ctrl.hidden || ctrl.loading || ctrl.index < 0 || ctrl.matches.length < 1) return; select(ctrl.index); break; case $mdConstant.KEY_CODE.ESCAPE: diff --git a/src/components/chips/chips.spec.js b/src/components/chips/chips.spec.js index 3a1761baf06..a8671ec9894 100644 --- a/src/components/chips/chips.spec.js +++ b/src/components/chips/chips.spec.js @@ -129,6 +129,44 @@ describe('', function() { expect(scope.items[3]).toBe('GrapeGrape'); }); + it('should add the chip if md-on-append is used only as a notifier (i.e. it returns nothing)', function() { + var element = buildChips(CHIP_APPEND_TEMPLATE); + var ctrl = element.controller('mdChips'); + + var noReturn = function(text) { + }; + scope.appendChip = jasmine.createSpy('appendChip').and.callFake(noReturn); + + element.scope().$apply(function() { + ctrl.chipBuffer = 'Grape'; + simulateInputEnterKey(ctrl); + }); + + expect(scope.appendChip).toHaveBeenCalled(); + expect(scope.appendChip.calls.mostRecent().args[0]).toBe('Grape'); + expect(scope.items.length).toBe(4); + expect(scope.items[3]).toBe('Grape'); + }); + + it('should not add the chip if md-on-append returns null', function() { + var element = buildChips(CHIP_APPEND_TEMPLATE); + var ctrl = element.controller('mdChips'); + + var nullChip = function(text) { + return null; + }; + scope.appendChip = jasmine.createSpy('appendChip').and.callFake(nullChip); + + element.scope().$apply(function() { + ctrl.chipBuffer = 'Grape'; + simulateInputEnterKey(ctrl); + }); + + expect(scope.appendChip).toHaveBeenCalled(); + expect(scope.appendChip.calls.mostRecent().args[0]).toBe('Grape'); + expect(scope.items.length).toBe(3); + }); + it('should call the remove method when removing a chip', function() { var element = buildChips(CHIP_REMOVE_TEMPLATE); var ctrl = element.controller('mdChips'); @@ -328,6 +366,80 @@ describe('', function() { expect(scope.items[3]).toBe('Kiwi'); expect(element.find('input').val()).toBe(''); })); + + it('simultaneously allows selecting an existing chip AND adding a new one', inject(function($mdConstant) { + // Setup our scope and function + setupScopeForAutocomplete(); + scope.onAppend = jasmine.createSpy('onAppend'); + + // Modify the base template to add md-on-append + var modifiedTemplate = AUTOCOMPLETE_CHIPS_TEMPLATE + .replace('Use an input element to build an ordered set

Use md-autocomplete to build an ordered set of chips.

- + Use md-autocomplete to build an ordered set o + + Tell the autocomplete to require a match (when enabled you cannot create new chips) + +

Vegetable Options

diff --git a/src/components/chips/demoCustomInputs/script.js b/src/components/chips/demoCustomInputs/script.js index dcd4f265876..884aed95452 100644 --- a/src/components/chips/demoCustomInputs/script.js +++ b/src/components/chips/demoCustomInputs/script.js @@ -16,6 +16,21 @@ self.numberChips = []; self.numberChips2 = []; self.numberBuffer = ''; + self.autocompleteDemoRequireMatch = true; + self.onAppend = onAppend; + + /** + * Return the proper object when the append is called. + */ + function onAppend(chip) { + // If it is an object, it's already a known chip + if (angular.isObject(chip)) { + return chip; + } + + // Otherwise, create a new one + return { name: chip, type: 'new' } + } /** * Search for vegetables. diff --git a/src/components/chips/js/chipsController.js b/src/components/chips/js/chipsController.js index 8da8b75eb21..42e10d62f40 100644 --- a/src/components/chips/js/chipsController.js +++ b/src/components/chips/js/chipsController.js @@ -93,6 +93,11 @@ function MdChipsCtrl ($scope, $mdConstant, $log, $element, $timeout) { MdChipsCtrl.prototype.inputKeydown = function(event) { var chipBuffer = this.getChipBuffer(); + // If we have an autocomplete, and it handled the event, we have nothing to do + if (this.hasAutocomplete && event.isDefaultPrevented && event.isDefaultPrevented()) { + return; + } + switch (event.keyCode) { case this.$mdConstant.KEY_CODE.ENTER: if ((this.hasAutocomplete && this.requireMatch) || !chipBuffer) break; @@ -192,27 +197,32 @@ MdChipsCtrl.prototype.getAdjacentChipIndex = function(index) { * call out to the md-on-append method, if provided * @param newChip */ - MdChipsCtrl.prototype.appendChip = function(newChip) { - - // If useOnAppend and onAppend function is provided call it. - if (this.useOnAppend && this.onAppend) { - newChip = this.onAppend({'$chip': newChip}); - } - - // If items contains identical object to newChip do not append - if(angular.isObject(newChip)){ - var identical = this.items.some(function(item){ - return angular.equals(newChip, item); - }); - if(identical) return; - } - - // If items contains newChip do not append - if (this.items.indexOf(newChip) + 1) return; - - //add newChip to items - this.items.push(newChip); - }; +MdChipsCtrl.prototype.appendChip = function(newChip) { + if (this.useOnAppend && this.onAppend) { + var onAppendChip = this.onAppend({'$chip': newChip}); + + // Check to make sure the chip is defined before assigning it (the developer may be using + // md-on-append as only a notification and not returning anything, in which case we should still + // add the string chip). + if (angular.isDefined(onAppendChip)) { + newChip = onAppendChip; + } + } + + // If items contains identical object to newChip do not append + if(angular.isObject(newChip)){ + var identical = this.items.some(function(item){ + return angular.equals(newChip, item); + }); + if(identical) return; + } + + // Check for a null (but not undefined), or existing chip and cancel appending + if (newChip == null || this.items.indexOf(newChip) + 1) return; + + // Append the new chip onto our list + this.items.push(newChip); +}; /** * Sets whether to use the md-on-append expression. This expression is diff --git a/src/components/chips/js/chipsDirective.js b/src/components/chips/js/chipsDirective.js index 1c5f7f7ee11..20cdaa24b12 100644 --- a/src/components/chips/js/chipsDirective.js +++ b/src/components/chips/js/chipsDirective.js @@ -67,11 +67,16 @@ * displayed when there is at least on item in the list * @param {boolean=} readonly Disables list manipulation (deleting or adding list items), hiding * the input and delete buttons - * @param {expression} md-on-append An expression that when called expects you to return an object - * representation of the chip input string. + * @param {expression} md-on-append An expression of form `myFunction($chip)` that when called + * expects one of the following return values: + * - an object representing the `$chip` input string + * - `undefined` to simply add the `$chip` input string, or + * - `null` to prevent the chip from being appended * @param {expression=} md-on-remove An expression which will be called when a chip has been * removed. * @param {expression=} md-on-select An expression which will be called when a chip is selected. + * @param {boolean} md-require-match If true, and the chips template contains an autocomplete, + * only allow selection of pre-defined chips (i.e. you cannot add new ones). * @param {string=} delete-hint A string read by screen readers instructing users that pressing * the delete key will remove the chip. * @param {string=} delete-button-label A label for the delete button. Also hidden and read by