From f9948c643f4256d20157ee5571f4abb279fed575 Mon Sep 17 00:00:00 2001 From: Robert Messerle Date: Wed, 28 Jan 2015 10:19:02 -0800 Subject: [PATCH] feat(autocomplete): added initial files for autocomplete feat(autocomplete): adds accessibility support TODO: wire aria-activedescendant as a watched property with a value of the active listItem id refactor(autocomplete): re-organizes aria changes to live in controller chore(autocomplete): removes temporary comments refactor(autocomplete): renames ambiguous directive refactor(styles): renames `visuallyhidden` to `visually-hidden` for consistency. refactor(autocomplete): removes unused template file refactor(autocomplete): uses `$mdConstant` rather than hard-coded values refactor(autocomplete): various cleanup based on feedback refactor(autocomplete): cleans up scope confusion refactor(autocomplete): includes theming, updates directive name to prevent potential conflicts --- config/karma.conf.js | 2 +- docs/app/css/style.css | 11 -- docs/app/partials/menu-link.tmpl.html | 2 +- docs/app/partials/menu-toggle.tmpl.html | 2 +- .../autocomplete/autocomplete-theme.scss | 20 +++ src/components/autocomplete/autocomplete.js | 11 ++ src/components/autocomplete/autocomplete.scss | 145 +++++++++++++++++ .../autocomplete/autocomplete.spec.js | 64 ++++++++ .../autocomplete/demoBasicUsage/index.html | 18 +++ .../autocomplete/demoBasicUsage/script.js | 26 ++++ .../autocomplete/demoBasicUsage/style.css | 3 + .../autocomplete/js/autocompleteController.js | 147 ++++++++++++++++++ .../autocomplete/js/autocompleteDirective.js | 83 ++++++++++ .../autocomplete/js/highlightController.js | 22 +++ .../autocomplete/js/highlightDirective.js | 37 +++++ .../autocomplete/js/listItemDirective.js | 22 +++ src/core/style/structure.scss | 12 ++ 17 files changed, 613 insertions(+), 14 deletions(-) create mode 100644 src/components/autocomplete/autocomplete-theme.scss create mode 100644 src/components/autocomplete/autocomplete.js create mode 100644 src/components/autocomplete/autocomplete.scss create mode 100644 src/components/autocomplete/autocomplete.spec.js create mode 100644 src/components/autocomplete/demoBasicUsage/index.html create mode 100644 src/components/autocomplete/demoBasicUsage/script.js create mode 100644 src/components/autocomplete/demoBasicUsage/style.css create mode 100644 src/components/autocomplete/js/autocompleteController.js create mode 100644 src/components/autocomplete/js/autocompleteDirective.js create mode 100644 src/components/autocomplete/js/highlightController.js create mode 100644 src/components/autocomplete/js/highlightDirective.js create mode 100644 src/components/autocomplete/js/listItemDirective.js diff --git a/config/karma.conf.js b/config/karma.conf.js index be110ec5a89..4cf5cf8f021 100644 --- a/config/karma.conf.js +++ b/config/karma.conf.js @@ -10,7 +10,7 @@ module.exports = function(config) { // demos in the tests, and Karma doesn't support advanced // globbing. 'src/components/*/*.js', - 'src/components/tabs/js/*.js' + 'src/components/*/js/*.js' ]; var COMPILED_SRC = [ diff --git a/docs/app/css/style.css b/docs/app/css/style.css index 0339e6f8454..00ee92b4fef 100644 --- a/docs/app/css/style.css +++ b/docs/app/css/style.css @@ -83,17 +83,6 @@ code:not(.highlight) { -webkit-font-smoothing: auto; } -.visuallyhidden { - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - text-transform: none; - width: 1px; -} .md-sidenav-inner { background: #fff; } diff --git a/docs/app/partials/menu-link.tmpl.html b/docs/app/partials/menu-link.tmpl.html index 93bac37ffad..5604f1bed72 100644 --- a/docs/app/partials/menu-link.tmpl.html +++ b/docs/app/partials/menu-link.tmpl.html @@ -1,7 +1,7 @@ {{section | humanizeDoc}} - current page diff --git a/docs/app/partials/menu-toggle.tmpl.html b/docs/app/partials/menu-toggle.tmpl.html index 0a399b94eb0..ce7649aa289 100644 --- a/docs/app/partials/menu-toggle.tmpl.html +++ b/docs/app/partials/menu-toggle.tmpl.html @@ -6,7 +6,7 @@ {{section.name}} - + Toggle {{isOpen()? 'expanded' : 'collapsed'}} diff --git a/src/components/autocomplete/autocomplete-theme.scss b/src/components/autocomplete/autocomplete-theme.scss new file mode 100644 index 00000000000..41e698cfd8b --- /dev/null +++ b/src/components/autocomplete/autocomplete-theme.scss @@ -0,0 +1,20 @@ +md-autocomplete { + background: '{{background-50}}'; + button { + background: '{{background-200}}'; + } + ul { + background: '{{background-50}}'; + li { + border-top: 1px solid '{{background-400}}'; + color: '{{background-900}}'; + .highlight { + color: '{{background-600}}'; + } + &:hover, + &.selected { + background: '{{background-200}}'; + } + } + } +} diff --git a/src/components/autocomplete/autocomplete.js b/src/components/autocomplete/autocomplete.js new file mode 100644 index 00000000000..1402e26b744 --- /dev/null +++ b/src/components/autocomplete/autocomplete.js @@ -0,0 +1,11 @@ +(function () { + 'use strict'; + /** + * @ngdoc module + * @name material.components.autocomplete + */ + /* + * @see js folder for autocomplete implementation + */ + angular.module('material.components.autocomplete', [ 'material.core' ]); +})(); diff --git a/src/components/autocomplete/autocomplete.scss b/src/components/autocomplete/autocomplete.scss new file mode 100644 index 00000000000..d044311bc15 --- /dev/null +++ b/src/components/autocomplete/autocomplete.scss @@ -0,0 +1,145 @@ +@keyframes md-autocomplete-list-out { + 0% { + animation-timing-function: linear; + } + 50% { + opacity: 0; + height: 40px; + animation-timing-function: ease-in; + } + 100% { + height: 0; + opacity: 0; + } +} +@keyframes md-autocomplete-list-in { + 0% { + opacity: 0; + height: 0; + animation-timing-function: ease-out; + } + 50% { + opacity: 0; + height: 40px; + } + 100% { + opacity: 1; + height: 40px; + } +} +md-content { + overflow: visible; +} +md-autocomplete { + box-shadow: 0 2px 5px rgba(black, 0.25); + border-radius: 2px; + display: block; + height: 40px; + position: relative; + overflow: visible; + + md-autocomplete-wrap { + display: block; + position: relative; + overflow: visible; + height: 40px; + + md-progress-linear { + position: absolute; + bottom: 0; left: 0; width: 100%; + height: 3px; + transition: none; + + .md-container { + transition: none; + top: auto; + height: 3px; + } + &.ng-enter { + transition: opacity 0.15s linear; + &.ng-enter-active { + opacity: 1; + } + } + &.ng-leave { + transition: opacity 0.15s linear; + &.ng-leave-active { + opacity: 0; + } + } + } + } + input { + position: absolute; + left: 0; + top: 0; + width: 100%; + box-sizing: border-box; + border: none; + box-shadow: none; + padding: 0 15px; + font-size: 14px; + line-height: 40px; + height: 40px; + outline: none; + z-index: 2; + background: transparent; + } + button { + position: absolute; + top: 10px; + right: 10px; + line-height: 20px; + z-index: 2; + text-align: center; + width: 20px; + height: 20px; + cursor: pointer; + border: none; + border-radius: 50%; + padding: 0; + font-size: 12px; + &.ng-enter { + transform: scale(0); + transition: transform 0.15s ease-out; + &.ng-enter-active { + transform: scale(1); + } + } + &.ng-leave { + transition: transform 0.15s ease-out; + &.ng-leave-active { + transform: scale(0); + } + } + } + ul { + position: absolute; + top: 100%; + left: 0; + right: 0; + box-shadow: 0 2px 5px rgba(black, 0.25); + margin: 0; + list-style: none; + padding: 0; + overflow: auto; + max-height: 41px * 5.5; + li { + border-top: 1px solid #ddd; + padding: 0 15px; + line-height: 40px; + font-size: 14px; + overflow: hidden; + height: 40px; + transition: background 0.15s linear; + cursor: pointer; + margin: 0; + &.ng-enter { + animation: md-autocomplete-list-in 0.2s; + } + &.ng-leave { + animation: md-autocomplete-list-out 0.2s; + } + } + } +} diff --git a/src/components/autocomplete/autocomplete.spec.js b/src/components/autocomplete/autocomplete.spec.js new file mode 100644 index 00000000000..9cc42a91104 --- /dev/null +++ b/src/components/autocomplete/autocomplete.spec.js @@ -0,0 +1,64 @@ +describe('', function() { + + beforeEach(module('material.components.autocomplete')); + + function compile (str, scope) { + var container; + inject(function ($compile) { + container = $compile(str)(scope); + scope.$apply(); + }); + return container; + } + + function createScope () { + var scope; + var items = ['foo', 'bar', 'baz'].map(function (item) { return { display: item }; }); + inject(function ($rootScope) { + scope = $rootScope.$new(); + scope.match = function (term) { + return items.filter(function (item) { + return item.display.indexOf(term) === 0; + }); + }; + scope.searchText = ''; + scope.selectedItem = null; + }); + return scope; + } + + describe('basic functionality', function () { + it('should fail', inject(function($timeout, $mdConstant) { + var scope = createScope(); + var template = '\ + \ + {{item.display}}\ + '; + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + + expect(scope.searchText).toBe(''); + expect(scope.selectedItem).toBe(null); + + element.scope().searchText = 'fo'; + ctrl.keydown({}); + element.scope().$apply(); + $timeout.flush(); + + expect(scope.searchText).toBe('fo'); + expect(scope.match(scope.searchText).length).toBe(1); + expect(element.find('li').length).toBe(1); + + ctrl.keydown({ keyCode: $mdConstant.KEY_CODE.DOWN_ARROW, preventDefault: angular.noop }); + ctrl.keydown({ keyCode: $mdConstant.KEY_CODE.ENTER, preventDefault: angular.noop }); + scope.$apply(); + expect(scope.searchText).toBe('foo'); + })); + }); + +}); \ No newline at end of file diff --git a/src/components/autocomplete/demoBasicUsage/index.html b/src/components/autocomplete/demoBasicUsage/index.html new file mode 100644 index 00000000000..c37f9b04688 --- /dev/null +++ b/src/components/autocomplete/demoBasicUsage/index.html @@ -0,0 +1,18 @@ +
+ + + + + {{item.display}} + + +

Current search term: "{{ctrl.searchText}}"

+ +
+ +
diff --git a/src/components/autocomplete/demoBasicUsage/script.js b/src/components/autocomplete/demoBasicUsage/script.js new file mode 100644 index 00000000000..4e77f38db44 --- /dev/null +++ b/src/components/autocomplete/demoBasicUsage/script.js @@ -0,0 +1,26 @@ +angular + .module('autocompleteDemo', ['ngMaterial']) + .controller('DemoCtrl', DemoCtrl); + +function DemoCtrl ($timeout, $q) { + var self = this; + this.selectedItem = null; + this.searchText = null; + this.states = 'Alabama, Alaska, Arizona, Arkansas, California, Colorado, Connecticut, Deleware,\ + Florida, Georgia, Hawaii, Idaho, Illanois, 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'.split(/, +/g).map(function (state) { return { value: state.toLowerCase(), display: state }; }); + this.getItems = getItems; + + function getItems (query) { + if (!query) return []; + var deferred = $q.defer(); + var lowercaseQuery = angular.lowercase(query); + var results = self.states.filter(function (state) { return state.value.indexOf(lowercaseQuery) === 0; }); + $timeout(function () { deferred.resolve(results); }, Math.random() * 1000, false); + return deferred.promise; + } +} diff --git a/src/components/autocomplete/demoBasicUsage/style.css b/src/components/autocomplete/demoBasicUsage/style.css new file mode 100644 index 00000000000..33bcba73697 --- /dev/null +++ b/src/components/autocomplete/demoBasicUsage/style.css @@ -0,0 +1,3 @@ +md-content { + min-height: 500px; +} \ No newline at end of file diff --git a/src/components/autocomplete/js/autocompleteController.js b/src/components/autocomplete/js/autocompleteController.js new file mode 100644 index 00000000000..13ad812e48c --- /dev/null +++ b/src/components/autocomplete/js/autocompleteController.js @@ -0,0 +1,147 @@ +(function () { + 'use strict'; + angular + .module('material.components.autocomplete') + .controller('MdAutocompleteCtrl', MdAutocompleteCtrl); + + function MdAutocompleteCtrl ($scope, $element, $timeout, $q, $mdUtil, $mdConstant) { + + //-- private variables + var self = this, + itemParts = $scope.itemsExpr.split(/\ in\ /i), + itemExpr = itemParts[1], + elements = { + main: $element[0], + ul: $element[0].getElementsByTagName('ul')[0], + input: $element[0].getElementsByTagName('input')[0] + }, + promise = null, + cache = {}; + + //-- public variables + self.scope = $scope; + self.parent = $scope.$parent; + self.itemName = itemParts[0]; + self.matches = []; + self.loading = false; + self.hidden = true; + self.index = 0; + self.keydown = keydown; + self.clear = clearValue; + self.select = select; + self.fetch = $mdUtil.debounce(fetchResults); + + //-- return init + return init(); + + //-- start method definitions + function init () { + configureWatchers(); + configureAria(); + } + + function configureAria () { + var ul = angular.element(elements.ul), + input = angular.element(elements.input), + id = ul.attr('id') || 'ul_' + $mdUtil.nextUid(); + ul.attr('id', id); + input.attr('aria-owns', id); + } + + function configureWatchers () { + $scope.$watch('searchText', function (searchText) { + if (!searchText) { + self.loading = false; + return self.matches = []; + } + var term = searchText.toLowerCase(); + if (promise && promise.cancel) { + promise.cancel(); + promise = null; + } + if (cache[term]) { + self.matches = cache[term]; + } else if (!self.hidden) { + self.loading = true; + self.fetch(searchText); + } + }); + } + + function fetchResults (searchText) { + var items = $scope.$parent.$eval(itemExpr), + term = searchText.toLowerCase(); + promise = $q.when(items).then(function (matches) { + cache[term] = matches; + if (searchText !== $scope.searchText) return; //-- just cache the results if old request + promise = null; + self.loading = false; + self.matches = matches; + }); + } + + function keydown (event) { + switch (event.keyCode) { + case $mdConstant.KEY_CODE.DOWN_ARROW: + if (self.loading) return; + event.preventDefault(); + self.index = Math.min(self.index + 1, self.matches.length - 1); + updateScroll(); + break; + case $mdConstant.KEY_CODE.UP_ARROW: + if (self.loading) return; + event.preventDefault(); + self.index = Math.max(0, self.index - 1); + updateScroll(); + break; + case $mdConstant.KEY_CODE.ENTER: + if (self.loading) return; + event.preventDefault(); + select(self.index); + break; + case $mdConstant.KEY_CODE.ESCAPE: + self.matches = []; + self.hidden = true; + self.index = -1; + break; + default: + self.index = -1; + self.hidden = false; + //-- after value updates, check if list should be hidden + $timeout(function () { self.hidden = isHidden(); }); + } + } + + function clearValue () { + $scope.searchText = ''; + select(-1); + } + + function isHidden () { + return self.matches.length === 1 && $scope.searchText === getDisplayValue(self.matches[0]); + } + + function getDisplayValue (item) { + return (item && $scope.itemText) ? item[$scope.itemText] : item; + } + + function select (index) { + $scope.searchText = getDisplayValue(self.matches[index]) || $scope.searchText; + self.hidden = true; + self.index = -1; + self.matches = []; + } + + function updateScroll () { + var top = 41 * self.index, + bot = top + 41, + hgt = 41 * 5.5; + if (top < elements.ul.scrollTop) { + elements.ul.scrollTop = top; + } else if (bot > elements.ul.scrollTop + hgt) { + elements.ul.scrollTop = bot - hgt; + } + } + + } +})(); diff --git a/src/components/autocomplete/js/autocompleteDirective.js b/src/components/autocomplete/js/autocompleteDirective.js new file mode 100644 index 00000000000..7c861381f44 --- /dev/null +++ b/src/components/autocomplete/js/autocompleteDirective.js @@ -0,0 +1,83 @@ +(function () { + 'use strict'; + angular + .module('material.components.autocomplete') + .directive('mdAutocomplete', MdAutocomplete); + + /** + * @ngdoc directive + * @name mdAutocomplete + * @module material.components.autocomplete + * + * @description + * `` allows you to provide real-time suggestions as the user types. + * + * @param {string=} md-search-text A model to bind the search text to + * @param {object=} md-selected-item A model to bind the selected item to + * @param {expression=} md-items An expression in the format of `item in items` to iterate over matches for your search. + * @param {string=} md-item-text A property on your object used to convert your object to a string + * @param {placeholder=} Placeholder text that will be forwarded to the input. + * + * @usage + * + * + * {{item.display}} + * + * + */ + + function MdAutocomplete () { + return { + template: '\ + \ + \ + \ + \ + Clear\ + \ + \ + \ +
    \ +
  • \ +
  • \ +
\ + \ +

{{item.display}}

\ + ', + transclude: true, + controller: 'MdAutocompleteCtrl', + controllerAs: '$mdAutocompleteCtrl', + scope: { + searchText: '=mdSearchText', + selectedItem: '=mdSelectedItem', + itemsExpr: '@mdItems', + itemText: '@mdItemText', + placeholder: '@placeholder' + } + }; + } +})(); diff --git a/src/components/autocomplete/js/highlightController.js b/src/components/autocomplete/js/highlightController.js new file mode 100644 index 00000000000..f7815009558 --- /dev/null +++ b/src/components/autocomplete/js/highlightController.js @@ -0,0 +1,22 @@ +(function () { + 'use strict'; + angular + .module('material.components.autocomplete') + .controller('MdHighlightCtrl', MdHighlightCtrl); + + function MdHighlightCtrl ($scope, $element, $interpolate) { + var term = $element.attr('md-highlight-text'), + text = $interpolate($element.text())($scope); + $scope.$watch(term, function (term) { + var regex = new RegExp('^' + sanitize(term), 'i'), + html = text.replace(regex, '$&'); + $element.html(html); + }); + + function sanitize (term) { + if (!term) return term; + return term.replace(/[\*\[\]\(\)\{\}\\\^\$]/g, '\\$&'); + } + } + +})(); diff --git a/src/components/autocomplete/js/highlightDirective.js b/src/components/autocomplete/js/highlightDirective.js new file mode 100644 index 00000000000..8a0a795fd97 --- /dev/null +++ b/src/components/autocomplete/js/highlightDirective.js @@ -0,0 +1,37 @@ +(function () { + 'use strict'; + angular + .module('material.components.autocomplete') + .directive('mdHighlightText', MdHighlight); + + /** + * @ngdoc directive + * @name mdHighlightText + * @module material.components.autocomplete + * + * @description + * The `md-highlight-text` directive allows you to specify text that should be highlighted within + * an element. Highlighted text will be wrapped in `` which can + * be styled through CSS. Please note that child elements may not be used with this directive. + * + * @param {string=} md-highlight-text A model to be searched for + * + * @usage + * + * + *
    + *
  • + * {{result.text}} + *
  • + *
+ *
+ */ + + function MdHighlight () { + return { + terminal: true, + scope: false, + controller: 'MdHighlightCtrl' + }; + } +})(); diff --git a/src/components/autocomplete/js/listItemDirective.js b/src/components/autocomplete/js/listItemDirective.js new file mode 100644 index 00000000000..a63da8e48f6 --- /dev/null +++ b/src/components/autocomplete/js/listItemDirective.js @@ -0,0 +1,22 @@ +(function () { + 'use strict'; + angular + .module('material.components.autocomplete') + .directive('mdAutocompleteListItem', MdAutocompleteListItem); + + function MdAutocompleteListItem ($compile, $mdUtil) { + return { + require: '^?mdAutocomplete', + terminal: true, + link: link, + scope: false + }; + function link (scope, element, attr, ctrl) { + var newScope = ctrl.parent.$new(false, ctrl.parent); + var itemName = ctrl.scope.$eval(attr.mdAutocompleteListItem); + newScope[itemName] = scope.item; + $compile(element.contents())(newScope); + element.attr({ 'role': 'option', 'id': 'item_' + $mdUtil.nextUid() }); + } + } +})(); diff --git a/src/core/style/structure.scss b/src/core/style/structure.scss index 635bcb0ac4a..deb10738ea7 100644 --- a/src/core/style/structure.scss +++ b/src/core/style/structure.scss @@ -148,6 +148,18 @@ input { } } +.visually-hidden { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + text-transform: none; + width: 1px; +} + .md-shadow { position: absolute; top: 0;