From 786cb3b1642be623b21551e4c8aff9c11d53ca13 Mon Sep 17 00:00:00 2001 From: Thomas Burleson Date: Tue, 10 Feb 2015 22:08:06 -0600 Subject: [PATCH] feat(select): add select component and functionality update(select): remove scroll-y css in md-select-menu add keyboard controls add ARIA support (WIP) add hover effect fix mobile rendering add aria controls for select Closes #1562. --- .../select/demoBasicUsage/index.html | 14 + .../select/demoBasicUsage/script.js | 5 + .../select/demoBasicUsage/style.css | 14 + .../select/demoOptionGroups/index.html | 14 + .../select/demoOptionGroups/script.js | 13 + .../select/demoOptionGroups/style.css | 14 + .../demoOptionsWithAsyncSearch/index.html | 10 + .../demoOptionsWithAsyncSearch/script.js | 17 + .../demoOptionsWithAsyncSearch/style.css | 14 + src/components/select/select-theme.scss | 49 ++ src/components/select/select.js | 733 ++++++++++++++++++ src/components/select/select.scss | 146 ++++ src/components/select/select.spec.js | 581 ++++++++++++++ 13 files changed, 1624 insertions(+) create mode 100755 src/components/select/demoBasicUsage/index.html create mode 100755 src/components/select/demoBasicUsage/script.js create mode 100644 src/components/select/demoBasicUsage/style.css create mode 100644 src/components/select/demoOptionGroups/index.html create mode 100644 src/components/select/demoOptionGroups/script.js create mode 100644 src/components/select/demoOptionGroups/style.css create mode 100644 src/components/select/demoOptionsWithAsyncSearch/index.html create mode 100644 src/components/select/demoOptionsWithAsyncSearch/script.js create mode 100644 src/components/select/demoOptionsWithAsyncSearch/style.css create mode 100644 src/components/select/select-theme.scss create mode 100755 src/components/select/select.js create mode 100755 src/components/select/select.scss create mode 100755 src/components/select/select.spec.js diff --git a/src/components/select/demoBasicUsage/index.html b/src/components/select/demoBasicUsage/index.html new file mode 100755 index 00000000000..77dd72292c9 --- /dev/null +++ b/src/components/select/demoBasicUsage/index.html @@ -0,0 +1,14 @@ +
+

A select can be used to select a single element from a list:

+ + {{ opt }} + +

{{ neighborhood ? 'You selected ' + neighborhood : 'You haven\'t selected a neighborhood yet' }}

+ +

A select can also specify its own label and be used to pick multiple entries:

+ + {{ multiNeighborhood.length ? multiNeighborhood.join(', ') : 'Choose some' }} + {{ opt }} + +

{{ multiNeighborhood ? 'You selected ' + multiNeighborhood : 'You haven\'t selected anything yet' }}

+
diff --git a/src/components/select/demoBasicUsage/script.js b/src/components/select/demoBasicUsage/script.js new file mode 100755 index 00000000000..32797f9299a --- /dev/null +++ b/src/components/select/demoBasicUsage/script.js @@ -0,0 +1,5 @@ +angular.module('selectDemoBasic', ['ngMaterial']) +.controller('AppCtrl', function($scope) { + $scope.neighborhoods = ['Chelsea', 'Financial District', 'Midtown', 'West Village', 'Williamsburg']; + $scope.neighborhoods2 = ['Chelsea', 'Financial District', 'Lower Manhattan', 'Midtown', 'Soho', 'Upper Manhattan', 'West Village', 'Williamsburg' ]; +}); diff --git a/src/components/select/demoBasicUsage/style.css b/src/components/select/demoBasicUsage/style.css new file mode 100644 index 00000000000..179b2aa9b4c --- /dev/null +++ b/src/components/select/demoBasicUsage/style.css @@ -0,0 +1,14 @@ +p { + padding: 0 20px; + text-align: center; +} + +p.result { + font-size: 0.8em; + color: #777; +} + + +.demo-content { + min-height: 348px; +} diff --git a/src/components/select/demoOptionGroups/index.html b/src/components/select/demoOptionGroups/index.html new file mode 100644 index 00000000000..ee50a77c676 --- /dev/null +++ b/src/components/select/demoOptionGroups/index.html @@ -0,0 +1,14 @@ +
+

Option groups can help provide contextual groupings about the options provided:

+
+ + + {{topping.name}} + + + {{topping.name}} + + +

{{ favoriteTopping ? 'Your favorite topping is ' + favoriteTopping : 'Please select a topping'}}

+
+
diff --git a/src/components/select/demoOptionGroups/script.js b/src/components/select/demoOptionGroups/script.js new file mode 100644 index 00000000000..0ddb6070c79 --- /dev/null +++ b/src/components/select/demoOptionGroups/script.js @@ -0,0 +1,13 @@ +angular.module('selectDemoOptGroups', ['ngMaterial']) +.controller('SelectOptGroupController', function($scope) { + $scope.toppings = [ + { category: 'meat', name: 'Pepperoni' }, + { category: 'meat', name: 'Sausage' }, + { category: 'meat', name: 'Ground Beef' }, + { category: 'meat', name: 'Bacon' }, + { category: 'veg', name: 'Mushrooms' }, + { category: 'veg', name: 'Onion' }, + { category: 'veg', name: 'Green Pepper' }, + { category: 'veg', name: 'Green Olives' }, + ]; +}); diff --git a/src/components/select/demoOptionGroups/style.css b/src/components/select/demoOptionGroups/style.css new file mode 100644 index 00000000000..179b2aa9b4c --- /dev/null +++ b/src/components/select/demoOptionGroups/style.css @@ -0,0 +1,14 @@ +p { + padding: 0 20px; + text-align: center; +} + +p.result { + font-size: 0.8em; + color: #777; +} + + +.demo-content { + min-height: 348px; +} diff --git a/src/components/select/demoOptionsWithAsyncSearch/index.html b/src/components/select/demoOptionsWithAsyncSearch/index.html new file mode 100644 index 00000000000..01cbae27baa --- /dev/null +++ b/src/components/select/demoOptionsWithAsyncSearch/index.html @@ -0,0 +1,10 @@ +
+

Select can call an arbitrary function on show. If this function returns a promise, it will display a loading indicator while it is being resolved:

+
+ + {{ user ? user.name : 'Assign to user' }} + {{user.name}} + +

You have assigned the task to: {{ user ? user.name : 'No one yet' }}

+
+
diff --git a/src/components/select/demoOptionsWithAsyncSearch/script.js b/src/components/select/demoOptionsWithAsyncSearch/script.js new file mode 100644 index 00000000000..f7bc9a68eaa --- /dev/null +++ b/src/components/select/demoOptionsWithAsyncSearch/script.js @@ -0,0 +1,17 @@ +angular.module('selectDemoOptionsAsync', ['ngMaterial']) +.controller('SelectAsyncController', function($timeout, $scope) { + + $scope.loadUsers = function() { + // Use timeout to simulate a 650ms request. + $scope.users = []; + return $timeout(function() { + $scope.users = [ + { id: 1, name: 'Scooby Doo' }, + { id: 2, name: 'Shaggy Rodgers' }, + { id: 3, name: 'Fred Jones' }, + { id: 4, name: 'Daphne Blake' }, + { id: 5, name: 'Velma Dinkley' }, + ]; + }, 650); + }; +}); diff --git a/src/components/select/demoOptionsWithAsyncSearch/style.css b/src/components/select/demoOptionsWithAsyncSearch/style.css new file mode 100644 index 00000000000..179b2aa9b4c --- /dev/null +++ b/src/components/select/demoOptionsWithAsyncSearch/style.css @@ -0,0 +1,14 @@ +p { + padding: 0 20px; + text-align: center; +} + +p.result { + font-size: 0.8em; + color: #777; +} + + +.demo-content { + min-height: 348px; +} diff --git a/src/components/select/select-theme.scss b/src/components/select/select-theme.scss new file mode 100644 index 00000000000..a2850bcf116 --- /dev/null +++ b/src/components/select/select-theme.scss @@ -0,0 +1,49 @@ +md-select.md-THEME_NAME-theme { + &:focus { + .md-select-label { + border-bottom-color: '{{primary-color}}'; + color: '{{ foreground-1 }}'; + &.md-placeholder { + color: '{{ foreground-1 }}'; + } + } + &.md-accent .md-select-label { + border-bottom-color: '{{accent-color}}'; + } + &.md-warn .md-select-label { + border-bottom-color: '{{warn-color}}'; + } + } + .md-select-label { + &.md-placeholder { + color: '{{foreground-3}}'; + } + border-bottom-color: '{{foreground-4}}'; + } +} + +md-select-menu.md-THEME_NAME-theme { + + md-optgroup { + color: '{{foreground-2}}'; + md-option { + color: '{{foreground-1}}'; + } + } + md-option[selected] { + background-color: '{{primary-50}}'; + &:focus { + background-color: '{{primary-100}}'; + } + &.md-accent { + background-color: '{{accent-50}}'; + &:focus { + background-color: '{{accent-100}}'; + } + } + } + md-option:focus:not([selected]) { + background: '{{background-200}}'; + } + +} diff --git a/src/components/select/select.js b/src/components/select/select.js new file mode 100755 index 00000000000..ad0ccc9698a --- /dev/null +++ b/src/components/select/select.js @@ -0,0 +1,733 @@ +(function() { +'use strict'; + +/*************************************************** + +### TODO ### + +**IMPLEMENTATION** + +- [x] Async loading of md-options (eg no options for one second, then they all show up). +- [x] Style select button to match spec +- [x] Implement CSS for an md-progress-circular in the select while the options are loading. +- [x] Implement CSS for md-optgroup +- [X] Implement keyboard interaction +- [X] Implement ARIA & accessibility +- [x] Finish theming - theme color +- [x] Add element option to interimElement to avoid recompiling every time (?) + +### TODO - POST RC1 ### +- [ ] Abstract placement logic in $mdSelect service to $mdMenu service + +**DOCUMENTATION AND DEMOS** + +- [ ] ng-model with child mdOptions (basic) +- [ ] ng-model="foo" ng-model-options="{ trackBy: '$value.id' }" for objects +- [ ] mdOption with ng-value +- [ ] mdOption with value +- [ ] Multiple repeaters full of mdOptions +- [ ] md-optgroups +- [ ] Async fetching of options with a loader +- [ ] Usage with input inside +- [ ] Usage with custom element inside +- [ ] Usage with md-multiple + +***************************************************/ + +var SELECT_EDGE_MARGIN = 8; +var SELECT_PADDING = 8; +var selectNextId = 0; + +angular.module('material.components.select', [ + 'material.core', + 'material.components.backdrop' +]) +.directive('mdSelect', SelectDirective) +.directive('mdSelectMenu', SelectMenuDirective) +.directive('mdOption', OptionDirective) +.directive('mdOptgroup', OptgroupDirective) +.provider('$mdSelect', SelectProvider); + +/** + * @ngdoc directive + * @name mdSelect + * @restrict E + * @module material.components.select + * + * @description Displays a select box, bound to an ng-model. + * + * @param {expression} ng-model The model! + * @param {boolean=} multiple Whether it's multiple. + */ +function SelectDirective($mdSelect, $mdUtil, $q, $mdTheming) { + return { + restrict: 'E', + compile: compile + }; + + function compile(element, attr) { + // The user is allowed to provide a label for the select as md-select-label child + var labelEl = element.find('md-select-label').remove(); + + // If not provided, we automatically make one + if (!labelEl.length) { + // Use the input as a label if there's an input inside. + if ( (labelEl = element.find('input')).length ) { + // Remove the input, we won't keep it in the select menu that will be popping up + labelEl.remove(); + } else { + // Otherwise, create a label for the user + labelEl = angular.element('').html('{{' + attr.ngModel + ' ? ' + attr.ngModel + ': \'' + attr.placeholder + '\'}}'); + } + } + labelEl.append(''); + labelEl.addClass('md-select-label'); + labelEl.addClass('{{ ' + attr.ngModel + ' ? \'\' : \'md-placeholder\'}}'); + + // There's got to be an md-content inside. If there's not one, let's add it. + if (!element.find('md-content').length) { + element.append( angular.element('').append(element.contents()) ); + } + + // Add progress spinner for md-options-loading + if (attr.mdOnOpen) { + element.find('md-content').prepend( + angular.element('') + .attr('md-mode', 'indeterminate') + .attr('ng-hide', '$$loadingAsyncDone') + .wrap('
') + .parent() + ); + } + + // Use everything that's left inside element.contents() as the contents of the menu + var selectTemplate = '' + + '
' + + '' + + element.html() + + '
'; + + element.empty().append(labelEl); + + $mdTheming(element); + + return function postLink(scope, element, attr) { + element.on('click', openSelect); + + element.on('keydown', openOnKeypress); + + element.attr({ + 'role': 'combobox', + 'id': 'select_' + $mdUtil.nextUid(), + 'aria-haspopup': true, + 'aria-expanded': 'false' + }); + + function openOnKeypress(e) { + var allowedCodes = [32, 13, 38, 40]; + if (allowedCodes.indexOf(e.keyCode) != -1 ) { + // prevent page scrolling on interaction + e.preventDefault(); + openSelect(e); + } + } + + function openSelect(ev) { + scope.$evalAsync(function() { + $mdSelect.show({ + scope: scope.$new(), + template: selectTemplate, + target: element[0], + hasBackdrop: true, + loadingAsync: attr.mdOnOpen ? scope.$eval(attr.mdOnOpen) : false + }).then(function() { + element.attr('aria-expanded', false); + }); + }); + } + }; + + } +} + +function SelectMenuDirective($parse, $mdSelect, $mdUtil, $mdTheming) { + + return { + restrict: 'E', + require: ['mdSelectMenu', 'ngModel'], + controller: SelectMenuController, + link: { + pre: preLink + } + }; + + // We use preLink instead of postLink to ensure that the select is initialized before + // its child options run postLink. + function preLink(scope, element, attr, ctrls) { + var selectCtrl = ctrls[0]; + var ngModel = ctrls[1]; + + $mdTheming(element); + element.on('click', clickListener); + element.on('keypress', keyListener); + selectCtrl.init(ngModel); + configureAria(); + + function configureAria() { + element.attr({ + 'id': 'select_menu_' + $mdUtil.nextUid(), + 'role': 'listbox', + 'aria-multiselectable': (selectCtrl.isMultiple ? 'true' : 'false') + }); + } + + function keyListener(e) { + if (e.keyCode == 13 || e.keyCode == 32) { + clickListener(e); + } + } + + function clickListener(ev) { + var option = $mdUtil.getClosest(ev.target, 'md-option'); + var optionCtrl = option && angular.element(option).data('$mdOptionController'); + if (!option || !optionCtrl) return; + + var optionHashKey = selectCtrl.hashGetter(optionCtrl.value); + var isSelected = angular.isDefined(selectCtrl.selected[optionHashKey]); + + scope.$apply(function() { + if (selectCtrl.isMultiple) { + if (isSelected) { + selectCtrl.deselect(optionHashKey); + } else { + selectCtrl.select(optionHashKey, optionCtrl.value); + } + } else { + if (!isSelected) { + selectCtrl.deselect( Object.keys(selectCtrl.selected)[0] ); + selectCtrl.select( optionHashKey, optionCtrl.value ); + } + } + selectCtrl.refreshViewValue(); + }); + } + } + + function SelectMenuController($scope, $element, $attrs) { + var self = this; + self.isMultiple = angular.isDefined($attrs.multiple); + // selected is an object with keys matching all of the selected options' hashed values + self.selected = {}; + // options is an object with keys matching every option's hash value, + // and values matching every option's controller. + self.options = {}; + + + self.init = function(ngModel) { + self.ngModel = ngModel; + + // Allow users to provide `ng-model="foo" ng-model-options="{trackBy: 'foo.id'}"` so + // that we can properly compare objects set on the model to the available options + if (ngModel.$options && ngModel.$options.trackBy) { + var trackByLocals = {}; + var trackByParsed = $parse(ngModel.$options.trackBy); + self.hashGetter = function(value, valueScope) { + trackByLocals.$value = value; + return trackByParsed(valueScope || $scope, trackByLocals); + }; + // If the user doesn't provide a trackBy, we automatically generate an id for every + // value passed in + } else { + self.hashGetter = function getHashValue(value) { + if (angular.isObject(value)) { + return '$$object_' + (value.$$mdSelectId || (value.$$mdSelectId = ++selectNextId)); + } + return value; + }; + } + + if (self.isMultiple) { + ngModel.$validators['md-multiple'] = validateArray; + ngModel.$render = renderMultiple; + + // watchCollection on the model because by default ngModel only watches the model's + // reference. This allowed the developer to also push and pop from their array. + $scope.$watchCollection($attrs.ngModel, function(value) { + if (validateArray(value)) renderMultiple(value); + }); + } else { + ngModel.$render = renderSingular; + } + + function validateArray(modelValue, viewValue) { + // If a value is truthy but not an array, reject it. + // If value is undefined/falsy, accept that it's an empty array. + return angular.isArray(modelValue || viewValue || []); + } + }; + + self.select = function(hashKey, hashedValue) { + var option = self.options[hashKey]; + option && option.setSelected(true); + self.selected[hashKey] = hashedValue; + }; + self.deselect = function(hashKey) { + var option = self.options[hashKey]; + option && option.setSelected(false); + delete self.selected[hashKey]; + }; + + self.addOption = function(hashKey, optionCtrl) { + if (angular.isDefined(self.options[hashKey])) { + throw new Error('Duplicate md-option values are not allowed in a select. ' + + 'Duplicate value "' + optionCtrl.value + '" found.'); + } + self.options[hashKey] = optionCtrl; + + // If this option's value was already in our ngModel, go ahead and select it. + if (angular.isDefined(self.selected[hashKey])) { + self.select(hashKey, optionCtrl.value); + self.refreshViewValue(); + } + }; + self.removeOption = function(hashKey, optionCtrl) { + delete self.options[hashKey]; + // Don't deselect an option when it's removed - the user's ngModel should be allowed + // to have values that do not match a currently available option. + }; + + self.refreshViewValue = function() { + var values = []; + var option; + for (var hashKey in self.selected) { + // If this hashKey has an associated option, push that option's value to the model. + if ((option = self.options[hashKey])) { + values.push(option.value); + } else { + // Otherwise, the given hashKey has no associated option, and we got it + // from an ngModel value at an earlier time. Push the unhashed value of + // this hashKey to the model. + // This allows the developer to put a value in the model that doesn't yet have + // an associated option. + values.push(self.selected[hashKey]); + } + } + self.ngModel.$setViewValue(self.isMultiple ? values : values[0]); + }; + + function renderMultiple() { + var newSelectedValues = self.ngModel.$modelValue || self.ngModel.$viewValue; + if (!angular.isArray(newSelectedValues)) return; + + var oldSelected = Object.keys(self.selected); + + var newSelectedHashes = newSelectedValues.map(self.hashGetter); + var deselected = oldSelected.filter(function(hash) { + return newSelectedHashes.indexOf(hash) === -1; + }); + + deselected.forEach(self.deselect); + newSelectedHashes.forEach(function(hashKey, i) { + self.select(hashKey, newSelectedValues[i]); + }); + } + function renderSingular() { + var value = self.ngModel.$viewValue || self.ngModel.$modelValue; + Object.keys(self.selected).forEach(self.deselect); + self.select( self.hashGetter(value), value ); + } + } + +} + +function OptionDirective($mdInkRipple, $mdUtil) { + + return { + restrict: 'E', + require: ['mdOption', '^^mdSelectMenu'], + controller: OptionController, + compile: compile + }; + + function compile(element, attr) { + // Manual transclusion to avoid the extra inner that ng-transclude generates + element.append( angular.element('
').append(element.contents()) ); + if (attr.tabindex === undefined) element.attr('tabindex', 0); + return postLink; + } + + function postLink(scope, element, attr, ctrls) { + var optionCtrl = ctrls[0]; + var selectCtrl = ctrls[1]; + + if (angular.isDefined(attr.ngValue)) { + scope.$watch(attr.ngValue, setOptionValue); + } else if (angular.isDefined(attr.value)) { + setOptionValue(attr.value); + } else { + throw new Error("Expected either ngValue or value attr"); + } + + $mdInkRipple.attachButtonBehavior(scope, element); + configureAria(); + + function setOptionValue(newValue, oldValue) { + var oldHashKey = selectCtrl.hashGetter(oldValue, scope); + var newHashKey = selectCtrl.hashGetter(newValue, scope); + + optionCtrl.hashKey = newHashKey; + optionCtrl.value = newValue; + + selectCtrl.removeOption(oldHashKey, optionCtrl); + selectCtrl.addOption(newHashKey, optionCtrl); + } + + scope.$on('$destroy', function() { + selectCtrl.removeOption(optionCtrl.hashKey, optionCtrl); + }); + + function configureAria() { + element.attr({ + 'role': 'option', + 'aria-selected': 'false', + 'id': 'select_option_'+ $mdUtil.nextUid() + }); + } + } + + function OptionController($scope, $element) { + this.selected = false; + this.setSelected = function(isSelected) { + if (isSelected && !this.selected) { + $element.attr({ + 'selected': 'selected', + 'aria-selected': 'true' + }); + } else if (!isSelected && this.selected) { + $element.removeAttr('selected'); + $element.attr('aria-selected', 'false'); + } + this.selected = isSelected; + }; + } + +} + +function OptgroupDirective() { + return { + restrict: 'E', + compile: compile + }; + function compile(el, attrs) { + var labelElement = el.find('label'); + if (!labelElement.length) { + labelElement = angular.element('