From 87295b05e266df42d9cb2d154c74bdbcb8a57486 Mon Sep 17 00:00:00 2001 From: Ty Potter Date: Tue, 17 Mar 2015 10:55:34 -0700 Subject: [PATCH] feat(chips): initial commit Supports basic list mutation (add, remove items). Does not yet support child-input elements (renders its own input element by default). TODO/future work laid out in directive comments. --- src/components/chips/chips-theme.scss | 6 + src/components/chips/chips.js | 13 ++ src/components/chips/chips.scss | 51 +++++ src/components/chips/chips.spec.js | 137 ++++++++++++ .../chips/demoBasicUsage/index.html | 45 ++++ src/components/chips/demoBasicUsage/script.js | 37 ++++ .../chips/js/chipRemoveDirective.js | 42 ++++ src/components/chips/js/chipsController.js | 196 ++++++++++++++++++ src/components/chips/js/chipsDirective.js | 161 ++++++++++++++ 9 files changed, 688 insertions(+) create mode 100644 src/components/chips/chips-theme.scss create mode 100644 src/components/chips/chips.js create mode 100644 src/components/chips/chips.scss create mode 100644 src/components/chips/chips.spec.js create mode 100644 src/components/chips/demoBasicUsage/index.html create mode 100644 src/components/chips/demoBasicUsage/script.js create mode 100644 src/components/chips/js/chipRemoveDirective.js create mode 100644 src/components/chips/js/chipsController.js create mode 100644 src/components/chips/js/chipsDirective.js diff --git a/src/components/chips/chips-theme.scss b/src/components/chips/chips-theme.scss new file mode 100644 index 00000000000..6492066f542 --- /dev/null +++ b/src/components/chips/chips-theme.scss @@ -0,0 +1,6 @@ +md-chips.md-THEME_NAME-theme { + .md-chip { + color: '{{background-contrast}}'; + background-color: '{{background-100}}'; + } +} diff --git a/src/components/chips/chips.js b/src/components/chips/chips.js new file mode 100644 index 00000000000..9c06dbf25b3 --- /dev/null +++ b/src/components/chips/chips.js @@ -0,0 +1,13 @@ +(function () { + 'use strict'; + /** + * @ngdoc module + * @name material.components.chips + */ + /* + * @see js folder for chips implementation + */ + angular.module('material.components.chips', [ + 'material.core' + ]); +})(); diff --git a/src/components/chips/chips.scss b/src/components/chips/chips.scss new file mode 100644 index 00000000000..4c959ae0a72 --- /dev/null +++ b/src/components/chips/chips.scss @@ -0,0 +1,51 @@ +$chip-font-size: 13px !default; +$chip-height: 22px !default; +$chip-button-padding: 2px !default; +$chip-padding: 0 8px 0 8px !default; +$chip-margin: 0 0 0 8px !default; + +.md-chips { + box-shadow: $whiteframe-shadow-z1; + display: block; + font-family: $font-family; + font-size: $chip-font-size; + padding: 8px; + vertical-align: middle; + + .md-chip { + border-radius: $chip-height / 2; + box-shadow: $whiteframe-shadow-z1; + display: inline-block; + height: $chip-height; + margin: $chip-margin; + line-height: $chip-height; + padding: $chip-padding; + transform: translate3d(0, 0, 0); + + &:first-child { + margin: 0; + } + + &.selected { + transform: translate3d(0, -1px, 0); + } + + .md-button { + padding: $chip-button-padding; + } + } + + .md-chip-worker { + display: inline-block; + line-height: $chip-height + 3px; /* 3px are added to height of chip for...the shadow? */ + + &:not(:first-child) { + margin: $chip-margin; + } + } + + .md-chip-input { + background-color:transparent; + border-width: 0px; + } +} diff --git a/src/components/chips/chips.spec.js b/src/components/chips/chips.spec.js new file mode 100644 index 00000000000..f6c93dc2574 --- /dev/null +++ b/src/components/chips/chips.spec.js @@ -0,0 +1,137 @@ +describe('', function() { + + beforeEach(module('material.components.chips')); + + function compile (str, scope) { + var container; + inject(function ($compile) { + container = $compile(str)(scope); + scope.$apply(); + }); + return container; + } + + function createScope () { + var scope; + var items = ['Apple', 'Banana', 'Orange']; + inject(function ($rootScope) { + scope = $rootScope.$new(); + scope.items = items; + }); + return scope; + } + + function getChipElements(root) { + return angular.element(root[0].querySelectorAll('div.md-chip')); + } + + describe('basic functionality', function () { + it('should render a default input element', function() { + var scope = createScope(); + var template = ''; + var element = compile(template, scope); + var ctrl = element.controller('mdChips'); + + element.scope().$apply(); + var input = element.find('input'); + expect(input.length).toBe(1); + expect(input).toHaveClass('md-chip-input'); + }); + + it('should render a list of chips', function() { + var scope = createScope(); + var template = ''; + var element = compile(template, scope); + var ctrl = element.controller('mdChips'); + + element.scope().$apply(); + var chips = getChipElements(element); + expect(chips.length).toBe(3); + expect(chips[0].innerHTML).toContain('Apple'); + expect(chips[1].innerHTML).toContain('Banana'); + expect(chips[2].innerHTML).toContain('Orange'); + }); + + it('should render a user-provided chip template', function() { + var scope = createScope(); + var template = + '' + + ' ' + + ''; + var element = compile(template, scope); + var ctrl = element.controller('mdChips'); + + element.scope().$apply(); + var chip = element.find('md-chip'); + expect(chip).toHaveClass('mychiptemplate'); + }); + + it('should add a chip', function() { + var scope = createScope(); + var template = ''; + var element = compile(template, scope); + var ctrl = element.controller('mdChips'); + + element.scope().$apply(); + ctrl.chipBuffer = 'Grape'; + element.scope().$apply(); + + ctrl.appendChipBuffer(); + element.scope().$apply(); + + var chips = getChipElements(element); + expect(chips.length).toBe(4); + expect(chips[0].innerHTML).toContain('Apple'); + expect(chips[1].innerHTML).toContain('Banana'); + expect(chips[2].innerHTML).toContain('Orange'); + expect(chips[3].innerHTML).toContain('Grape'); + }); + + it('should remove a chip', function() { + var scope = createScope(); + var template = ''; + var element = compile(template, scope); + var ctrl = element.controller('mdChips'); + + element.scope().$apply(); + + // Remove "Banana" + ctrl.removeChip(1); + element.scope().$apply(); + + var chips = getChipElements(element); + expect(chips.length).toBe(2); + expect(chips[0].innerHTML).toContain('Apple'); + expect(chips[1].innerHTML).toContain('Orange'); + }); + }); + + describe('', function() { + it('should remove a chip', function() { + var scope = createScope(); + var template = ''; + var element = compile(template, scope); + var ctrl = element.controller('mdChips'); + element.scope().$apply(); + + var chips = getChipElements(element); + expect(chips.length).toBe(3); + // Remove 'Banana' + var db = angular.element(chips[1]).find('md-button'); + db[0].click(); + element.scope().$apply(); + + chips = getChipElements(element); + expect(chips.length).toBe(2); + + // Remove 'Orange' + db = angular.element(chips[1]).find('md-button'); + db[0].click(); + element.scope().$apply(); + + chips = getChipElements(element); + expect(chips.length).toBe(1); + }); + }); + +}); diff --git a/src/components/chips/demoBasicUsage/index.html b/src/components/chips/demoBasicUsage/index.html new file mode 100644 index 00000000000..be92785e00f --- /dev/null +++ b/src/components/chips/demoBasicUsage/index.html @@ -0,0 +1,45 @@ +
+ + +

+ Display a readonly list of strings as chips, using a custom chip template. +

+ + + {{$chip}} + + +

+ Use the default chip template. +

+ + + +

+ Use Placeholders. +

+ + + +

+ Display a list of objects as chips. +

+ + + {{$chip.name}}:{{$chip.type}} + + +
+ Readonly + +
+
diff --git a/src/components/chips/demoBasicUsage/script.js b/src/components/chips/demoBasicUsage/script.js new file mode 100644 index 00000000000..dec76fc30b3 --- /dev/null +++ b/src/components/chips/demoBasicUsage/script.js @@ -0,0 +1,37 @@ +(function () { +angular + .module('contactchipsDemo', ['ngMaterial']) + .controller('DemoCtrl', DemoCtrl); + +function DemoCtrl ($timeout, $q) { + var self = this; + + self.readonly = false; + + // Lists of fruit names and Vegetable objects + self.fruitNames = ['Apple', 'Banana', 'Orange']; + self.roFruitNames = angular.copy(self.fruitNames); + self.newFruitNames = []; + self.vegObjs = [ + { + 'name' : 'Broccoli', + 'type' : 'cruciferous' + }, + { + 'name' : 'Cabbage', + 'type' : 'cruciferous' + }, + { + 'name' : 'Carrot', + 'type' : 'root' + } + ]; + + self.newVeg = function(chip) { + return { + name: chip, + type: 'unknown' + }; + }; +} +})(); diff --git a/src/components/chips/js/chipRemoveDirective.js b/src/components/chips/js/chipRemoveDirective.js new file mode 100644 index 00000000000..e34059f385e --- /dev/null +++ b/src/components/chips/js/chipRemoveDirective.js @@ -0,0 +1,42 @@ +(function () { + 'use strict'; + angular + .module('material.components.chips') + .directive('mdChipRemove', MdChipRemove); + + /** + * @ngdoc directive + * @name mdChipRemove + * @module material.components.chips + * + * @description + * `` + * Identifies an element within an as the delete button. This + * directive binds to that element's click event and removes the chip. + * + * @usage + * + * {{$chip}} + * + */ + + function MdChipRemove () { + return { + restrict: 'A', + require: ['^mdChips'], + link: function postLink(scope, element, attrs, controllers) { + var mdChipsCtrl = controllers[0]; + element.on('click', removeItemListener(mdChipsCtrl, scope)); + }, + scope: false + }; + + function removeItemListener(chipsCtrl, scope) { + return function() { + scope.$apply(function() { + chipsCtrl.removeChip(scope.$index); + }); + }; + } + } +})(); diff --git a/src/components/chips/js/chipsController.js b/src/components/chips/js/chipsController.js new file mode 100644 index 00000000000..68c7a83d412 --- /dev/null +++ b/src/components/chips/js/chipsController.js @@ -0,0 +1,196 @@ +(function () { + 'use strict'; + angular + .module('material.components.chips') + .controller('MdChipsCtrl', MdChipsCtrl); + + + + /** + * Controller for the MdChips component. Responsible for adding to and + * removing from the list of chips, marking chips as selected, and binding to + * the models of various input components. + * + * @param $mdUtil + * @param $mdConstant + * @param $log + * @ngInject + * @constructor + */ + function MdChipsCtrl ($mdUtil, $mdConstant, $log) { + /** @type {Object} */ + this.$mdConstant = $mdConstant; + + /** @type {$log} */ + this.$log = $log; + + /** @type {angular.NgModelController} */ + this.ngModelCtrl = null; + + /** @type {Object} */ + this.mdAutocompleteCtrl = null; + + /** @type {Array.} */ + this.items = []; + + /** @type {number} */ + this.selectedChip = -1; + + /** + * Model used by the input element. + * @type {string} + */ + this.chipBuffer = ''; + + /** + * Whether to use the mdChipAppend expression to transform the chip buffer + * before appending it to the list. + * @type {boolean} + */ + this.useMdChipAppend = false; + + /** + * Whether the Chip buffer is driven by an input element provided by the + * caller. + * @type {boolean} + */ + this.hasInputElement = false; + } + + + /** + * Handles the keydown event on the input element: appends the + * buffer to the chip list, while backspace removes the last chip in the list + * if the current buffer is empty. + * @param event + */ + MdChipsCtrl.prototype.defaultInputKeydown = function(event) { + switch (event.keyCode) { + case this.$mdConstant.KEY_CODE.ENTER: + event.preventDefault(); + this.appendChipBuffer(); + break; + case this.$mdConstant.KEY_CODE.BACKSPACE: // backspace + if (!this.chipBuffer) { + event.preventDefault(); + // TODO(typotter): Probably want to open the previous one for edit instead. + this.removeChip(this.items.length - 1); + } + break; + default: + } + }; + + + /** + * Sets the selected chip index to -1. + */ + MdChipsCtrl.prototype.resetSelectedChip = function() { + this.selectedChip = -1; + }; + + + /** + * Append the contents of the buffer to the chip list. This method will first + * call out to the md-chip-append method, if provided + */ + MdChipsCtrl.prototype.appendChipBuffer = function() { + var newChip = this.getChipBuffer(); + if (this.useMdChipAppend && this.mdChipAppend) { + newChip = this.mdChipAppend({'$chip': newChip}); + } + this.items.push(newChip); + this.resetChipBuffer(); + }; + + + /** + * Sets whether to use the md-chip-append expression. This expression is + * bound to scope and controller in {@code MdChipsDirective} as + * {@code mdChipAppend}. Due to the nature of directive scope bindings, the + * controller cannot know on its own/from the scope whether an expression was + * actually provided. + */ + MdChipsCtrl.prototype.useMdChipAppendExpression = function() { + this.useMdChipAppend = true; + }; + + + /** + * Gets the input buffer. The input buffer can be the model bound to the + * default input item {@code this.chipBuffer}, the {@code selectedItem} + * model of an {@code md-autocomplete}, or, through some magic, the model + * bound to any inpput or text area element found within a + * {@code md-input-container} element. + * @return {Object|string} + */ + MdChipsCtrl.prototype.getChipBuffer = function() { + if (this.mdAutocompleteCtrl) { + this.$log.error('md-autocomplete not yet supported'); + } else if (this.hasInputElement) { + this.$log.error('user-provided inputs not yet supported'); + } else { + return this.chipBuffer; + } + }; + + + /** + * Resets the input buffer. + */ + MdChipsCtrl.prototype.resetChipBuffer = function() { + if (this.mdAutocompleteCtrl) { + this.$log.error('md-autocomplete not yet supported'); + } else { + this.chipBuffer = ''; + } + }; + + + /** + * Removes the chip at the given index. + * @param index + */ + MdChipsCtrl.prototype.removeChip = function(index) { + this.items.splice(index, 1); + }; + + + /** + * Marks the chip at the given index as selected. + * @param index + */ + MdChipsCtrl.prototype.selectChip = function(index) { + if (index >= 0 && index <= this.items.length) { + this.selectedChip = index; + } else { + this.$log.warn('Selected Chip index out of bounds; ignoring.'); + } + }; + + + /** + * Configures the required interactions with the ngModel Controller. + * Specifically, set {@code this.items} to the {@code NgModelCtrl#$viewVale}. + * @param ngModelCtrl + */ + MdChipsCtrl.prototype.configureNgModel = function(ngModelCtrl) { + this.ngModelCtrl = ngModelCtrl; + + var self = this; + ngModelCtrl.$render = function() { + // model is updated. do something. + self.items = self.ngModelCtrl.$viewValue; + }; + }; + + + /** + * Configure bindings with the MdAutocomplete control. + * @param mdAutocompleteCtrl + */ + MdChipsCtrl.prototype.configureMdAutocomplete = function(mdAutocompleteCtrl) { + this.mdAutocompleteCtrl = mdAutocompleteCtrl; + // TODO(typotter): create and register a selectedItem watcher with mdAutocompleteCtrl. + }; +})(); diff --git a/src/components/chips/js/chipsDirective.js b/src/components/chips/js/chipsDirective.js new file mode 100644 index 00000000000..d0e30229896 --- /dev/null +++ b/src/components/chips/js/chipsDirective.js @@ -0,0 +1,161 @@ +(function () { + 'use strict'; + angular + .module('material.components.chips') + .directive('mdChips', MdChips); + /** + * @ngdoc directive + * @name mdChips + * @module material.components.chips + * + * @description + * `` is an input component for building lists of strings or objects. The list items are displayed as + * 'chips'. This component can make use of an element or an element. + * + * NOTE(typotter): This component is a WORK IN PROGRESS. If you use it now, it will probably break on you in the + * future. + * + * TODO(typotter): + * Expand input controls: + * Support md-autocomplete + * plain tag as child + * textarea input + * md-input?? + * List Manipulation + * delete item via DEL or backspace keys when selected + * Validation + * de-dupe values (or support duplicates, but fix the ng-repeat duplicate key issue) + * allow a validation callback + * hilighting style for invalid chips + * Item mutation + * Support template, show/hide the edit element on tap/click? double tap/double click? + * Truncation and Disambiguation (?) + * Truncate chip text where possible, but do not truncate entries such that two are indistinguishable. + * Drag and Drop (?) + * Drag and drop chips between related elements. + * + * + * @param {string=|object=} ng-model A model to bind the list of items to + * @param {string=} placeholder Placeholder text that will be forwarded to the input. + * @param {string=} secondary-placeholder Placeholder text that will be forwarded to the input, 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-chip-append An expression expected to convert the input string into an object when adding + * a chip. + * + * @usage + * + * + * + * + * + */ + + + var MD_CHIPS_TEMPLATE = '\ + \ +
\ +
\ +
\ +
\ +
\ +
'; + + var CHIP_INPUT_TEMPLATE = '\ + '; + + var CHIP_DEFAULT_TEMPLATE = '\ + \ + {{$chip}}\ + x\ + '; + + + /** + * MDChips Directive Definition + * @param $mdTheming + * @param $log + * @ngInject + */ + function MdChips ($mdTheming, $log) { + return { + template: function(element, attrs) { + // Clone the element into an attribute. By prepending the attribute + // name with '$', Angular won't write it into the DOM. The cloned + // element propagates to the link function via the attrs argument, + // where various contained-elements can be consumed. + attrs['$mdUserTemplate'] = element.clone(); + return MD_CHIPS_TEMPLATE; + }, + require: ['ngModel', 'mdChips'], + restrict: 'E', + controller: 'MdChipsCtrl', + controllerAs: '$mdChipsCtrl', + bindToController: true, + compile: compile, + scope: { + readonly: '=readonly', + placeholder: '@', + secondaryPlaceholder: '@', + mdChipAppend: '&' + } + }; + function compile(element, attr) { + var userTemplate = attr['$mdUserTemplate']; + var chipEl = userTemplate.find('md-chip'); + if (chipEl.length === 0) { + chipEl = angular.element(CHIP_DEFAULT_TEMPLATE); + } else { + // Warn if no remove button is included in the template. + if (!chipEl[0].querySelector('[md-chip-remove]')) { + $log.warn('md-chip-remove attribute not found in md-chip template.'); + } + } + var listNode = angular.element(element[0].querySelector('.md-chip')); + listNode.append(chipEl); + + // Input Element: Look for an autocomplete or an input. + var inputEl = userTemplate.find('md-autocomplete'); + var hasAutocomplete = inputEl.length > 0; + + if (!hasAutocomplete) { + // TODO(typotter): Check for an input or a textarea + + // Default element. + inputEl = angular.element(CHIP_INPUT_TEMPLATE); + var workerChip = angular.element(element[0].querySelector('.md-chip-worker')); + workerChip.append(inputEl); + } + + return function postLink(scope, element, attrs, controllers) { + $mdTheming(element); + var ngModelCtrl = controllers[0]; + var mdChipsCtrl = controllers[1]; + mdChipsCtrl.configureNgModel(ngModelCtrl); + + if (attrs.mdChipAppend) { + mdChipsCtrl.useMdChipAppendExpression(); + } + + if (hasAutocomplete) { + // TODO(typotter): Tell the mdChipsCtrl about the mdAutocompleteCtrl and have it + // watch the selectedItem model. + $log.error('md-autocomplete not yet supported'); + } + }; + } + } +})();