Skip to content
This repository has been archived by the owner on Sep 5, 2024. It is now read-only.

Commit

Permalink
feat(chips): Support multiple return values for md-on-append.
Browse files Browse the repository at this point in the history
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 display issue 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.
  • Loading branch information
topherfangio committed Nov 2, 2015
1 parent 2df6a6a commit 804e4bb
Show file tree
Hide file tree
Showing 10 changed files with 309 additions and 25 deletions.
37 changes: 37 additions & 0 deletions src/components/autocomplete/demoInsideDialog/dialog.tmpl.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<md-dialog aria-label="Autocomplete Dialog Example" ng-cloak>
<md-toolbar>
<div class="md-toolbar-tools">
<h2>Autocomplete Dialog Example</h2>
<span flex></span>
<md-button class="md-icon-button" ng-click="ctrl.cancel()">
<md-icon md-svg-src="img/icons/ic_close_24px.svg" aria-label="Close dialog"></md-icon>
</md-button>
</div>
</md-toolbar>

<md-dialog-content>
<div class="md-dialog-content">
<form ng-submit="$event.preventDefault()">
<p>Use <code>md-autocomplete</code> to search for matches from local or remote data sources.</p>
<md-autocomplete
md-selected-item="ctrl.selectedItem"
md-search-text="ctrl.searchText"
md-items="item in ctrl.querySearch(ctrl.searchText)"
md-item-text="item.display"
md-min-length="0"
placeholder="What is your favorite US state?">
<md-item-template>
<span md-highlight-text="ctrl.searchText" md-highlight-flags="^i">{{item.display}}</span>
</md-item-template>
<md-not-found>
No states matching "{{ctrl.searchText}}" were found.
</md-not-found>
</md-autocomplete>
</form>
</div>
</md-dialog-content>

<div class="md-actions">
<md-button aria-label="Finished" ng-click="ctrl.finish($event)">Finished</md-button>
</div>
</md-dialog>
9 changes: 9 additions & 0 deletions src/components/autocomplete/demoInsideDialog/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<div ng-controller="DemoCtrl as ctrl" layout="column" ng-cloak>
<md-content class="md-padding">
<p>
Click the button below to open the dialog with an autocomplete.
</p>

<md-button ng-click="ctrl.openDialog($event)" class="md-raised">Open Dialog</md-button>
</md-content>
</div>
84 changes: 84 additions & 0 deletions src/components/autocomplete/demoInsideDialog/script.js
Original file line number Diff line number Diff line change
@@ -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);
};

}
}
})();
2 changes: 1 addition & 1 deletion src/components/autocomplete/js/autocompleteController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
112 changes: 112 additions & 0 deletions src/components/chips/chips.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,44 @@ describe('<md-chips>', 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');
Expand Down Expand Up @@ -328,6 +366,80 @@ describe('<md-chips>', 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('<md-chips', '<md-chips md-on-append="onAppend($chip)"');

var element = buildChips(modifiedTemplate);

var ctrl = element.controller('mdChips');
$timeout.flush(); // mdAutcomplete needs a flush for its init.
var autocompleteCtrl = element.find('md-autocomplete').controller('mdAutocomplete');

element.scope().$apply(function() {
autocompleteCtrl.scope.searchText = 'K';
});
autocompleteCtrl.focus();
$timeout.flush();

/*
* Send a down arrow/enter to select the right fruit
*/
var downArrowEvent = {
type: 'keydown',
keyCode: $mdConstant.KEY_CODE.DOWN_ARROW,
which: $mdConstant.KEY_CODE.DOWN_ARROW
};
var enterEvent = {
type: 'keydown',
keyCode: $mdConstant.KEY_CODE.ENTER,
which: $mdConstant.KEY_CODE.ENTER
};
element.find('input').triggerHandler(downArrowEvent);
element.find('input').triggerHandler(enterEvent);
$timeout.flush();

// Check our onAppend calls
expect(scope.onAppend).not.toHaveBeenCalledWith('K');
expect(scope.onAppend).toHaveBeenCalledWith('Kiwi');
expect(scope.onAppend.calls.count()).toBe(1);

// Check our output
expect(scope.items.length).toBe(4);
expect(scope.items[3]).toBe('Kiwi');
expect(element.find('input').val()).toBe('');

// Reset our jasmine spy
scope.onAppend.calls.reset();

/*
* Use the "new chip" functionality
*/

// Set the search text
element.scope().$apply(function() {
autocompleteCtrl.scope.searchText = 'Acai Berry';
});

// Fire our event and flush any timeouts
element.find('input').triggerHandler(enterEvent);
$timeout.flush();

// Check our onAppend calls
expect(scope.onAppend).toHaveBeenCalledWith('Acai Berry');
expect(scope.onAppend.calls.count()).toBe(1);

// Check our output
expect(scope.items.length).toBe(5);
expect(scope.items[4]).toBe('Acai Berry');
expect(element.find('input').val()).toBe('');
}));
});

describe('user input templates', function() {
Expand Down
6 changes: 6 additions & 0 deletions src/components/chips/demoContactChips/style.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
md-content.autocomplete {
min-height: 250px;

// NOTE: Due to a bug with the virtual repeat sizing, we must manually set the width of
// the input so that the autocomplete popup will be properly sized. See issue #4450.
input {
min-width: 400px;
}
}
.md-item-text.compact {
padding-top: 8px;
Expand Down
8 changes: 7 additions & 1 deletion src/components/chips/demoCustomInputs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ <h2 class="md-title">Use an <code>input</code> element to build an ordered set
<br/>
<h2 class="md-title">Use <code>md-autocomplete</code> to build an ordered set of chips.</h2>

<md-chips ng-model="ctrl.selectedVegetables" md-autocomplete-snap md-require-match="true">
<md-chips ng-model="ctrl.selectedVegetables" md-autocomplete-snap
md-on-append="ctrl.onAppend($chip)"
md-require-match="ctrl.autocompleteDemoRequireMatch">
<md-autocomplete
md-selected-item="ctrl.selectedItem"
md-search-text="ctrl.searchText"
Expand All @@ -32,6 +34,10 @@ <h2 class="md-title">Use <code>md-autocomplete</code> to build an ordered set o
</md-chip-template>
</md-chips>

<md-checkbox ng-model="ctrl.autocompleteDemoRequireMatch">
Tell the autocomplete to require a match (when enabled you cannot create new chips)
</md-checkbox>

<br />
<h2 class="md-title">Vegetable Options</h2>

Expand Down
15 changes: 15 additions & 0 deletions src/components/chips/demoCustomInputs/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
52 changes: 31 additions & 21 deletions src/components/chips/js/chipsController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 804e4bb

Please sign in to comment.