UNPKG

angular-material

Version:

**[Support for legacy AngularJS ended on January 1st, 2022](https://goo.gle/angularjs-end-of-life). [See `@angular/core` for the actively supported Angular](https://npmjs.com/@angular/core).**

834 lines (758 loc) 24 kB
/*! * AngularJS Material Design * https://github.com/angular/material * @license MIT * v1.2.5 */ (function( window, angular, undefined ){ "use strict"; /** * @ngdoc module * @name material.components.navBar */ MdNavBar['$inject'] = ["$mdAria", "$mdTheming", "$window", "$mdUtil"]; MdNavBarController['$inject'] = ["$element", "$scope", "$timeout", "$mdConstant"]; MdNavItem['$inject'] = ["$mdAria", "$$rAF", "$mdUtil", "$window"]; MdNavItemController['$inject'] = ["$element"]; angular.module('material.components.navBar', ['material.core']) .controller('MdNavBarController', MdNavBarController) .directive('mdNavBar', MdNavBar) .controller('MdNavItemController', MdNavItemController) .directive('mdNavItem', MdNavItem); /** * @ngdoc directive * @name mdNavBar * @module material.components.navBar * * @restrict E * * @description * The `<md-nav-bar>` directive renders a list of material tabs that can be used * for top-level page navigation. Unlike `<md-tabs>`, it has no concept of a tab * body and no bar pagination. * * Because it deals with page navigation, certain routing concepts are built-in. * Route changes via `ng-href`, `ui-sref`, or `ng-click` events are supported. * Alternatively, the user could simply watch the value of `md-selected-nav-item` * (`currentNavItem` in the below example) for changes. * * Accessibility functionality is implemented as a * <a href="https://www.w3.org/TR/wai-aria-1.0/complete#tablist"> * tablist</a> with * <a href="https://www.w3.org/TR/wai-aria-1.0/complete#tab">tabs</a>. * We've kept the `role="navigation"` on the `<nav>`, for backwards compatibility, even though * it is not required in the * <a href="https://www.w3.org/TR/wai-aria-practices/#aria_lh_navigation"> * latest Working Group Note from December 2017</a>. * * <h3>Keyboard Navigation</h3> * - `Tab`/`Shift+Tab` moves the focus to the next/previous interactive element on the page * - `Enter`/`Space` selects the focused nav item and navigates to display the item's contents * - `Right`/`Down` moves focus to the next nav item, wrapping at the end * - `Left`/`Up` moves focus to the previous nav item, wrapping at the end * - `Home`/`End` moves the focus to the first/last nav item * * @param {string=} md-selected-nav-item The name of the current tab; this must * match the `name` attribute of `<md-nav-item>`. * @param {boolean=} md-no-ink-bar If set to true, the ink bar will be hidden. * @param {string=} nav-bar-aria-label An `aria-label` applied to the `md-nav-bar`'s tablist * for accessibility. * * @usage * <hljs lang="html"> * <md-nav-bar md-selected-nav-item="currentNavItem"> * <md-nav-item md-nav-click="goto('page1')" name="page1"> * Page One * </md-nav-item> * <md-nav-item md-nav-href="#page2" name="page3">Page Two</md-nav-item> * <md-nav-item md-nav-sref="page3" name="page2">Page Three</md-nav-item> * <md-nav-item * md-nav-sref="app.page4" * sref-opts="{reload: true, notify: true}" * name="page4"> * Page Four * </md-nav-item> * </md-nav-bar> *</hljs> * <hljs lang="js"> * (function() { * 'use strict'; * * $rootScope.$on('$routeChangeSuccess', function(event, current) { * $scope.currentLink = getCurrentLinkFromRoute(current); * }); * }); * </hljs> */ /** * @param $mdAria * @param $mdTheming * @param $window * @param $mdUtil * @constructor * ngInject */ function MdNavBar($mdAria, $mdTheming, $window, $mdUtil) { return { restrict: 'E', transclude: true, controller: MdNavBarController, controllerAs: 'ctrl', bindToController: true, scope: { 'mdSelectedNavItem': '=?', 'mdNoInkBar': '=?', 'navBarAriaLabel': '@?', }, template: '<div class="md-nav-bar">' + '<nav role="navigation">' + '<ul class="_md-nav-bar-list" ng-transclude role="tablist" ' + 'ng-focus="ctrl.onFocus()" ' + // Deprecated but kept for now in order to not break tests 'aria-label="{{ctrl.navBarAriaLabel}}">' + '</ul>' + '</nav>' + '<md-nav-ink-bar ng-hide="ctrl.mdNoInkBar"></md-nav-ink-bar>' + '</div>', link: function(scope, element, attrs, ctrl) { ctrl.width = $window.innerWidth; function onResize() { if (ctrl.width !== $window.innerWidth) { ctrl.updateSelectedTabInkBar(); ctrl.width = $window.innerWidth; scope.$digest(); } } function cleanUp() { angular.element($window).off('resize', onResize); } angular.element($window).on('resize', $mdUtil.debounce(onResize, 300)); scope.$on('$destroy', cleanUp); $mdTheming(element); if (!ctrl.navBarAriaLabel) { $mdAria.expectAsync(element, 'aria-label', angular.noop); } }, }; } /** * Controller for the nav-bar component. * Accessibility functionality is implemented as a tablist * (https://www.w3.org/TR/wai-aria-1.0/complete#tablist) and * tabs (https://www.w3.org/TR/wai-aria-1.0/complete#tab). * * @param {!JQLite} $element * @param {!IScope} $scope * @param {!ITimeoutService} $timeout * @param {!Object} $mdConstant * @constructor * @final * ngInject */ function MdNavBarController($element, $scope, $timeout, $mdConstant) { // Injected variables /** * @private @const * @type {!ITimeoutService} */ this._$timeout = $timeout; /** * @private @const * @type {!IScope} */ this._$scope = $scope; /** * @private @const * @type {!Object} */ this._$mdConstant = $mdConstant; // Data-bound variables. /** @type {?string} */ this.mdSelectedNavItem; /** @type {?string} */ this.navBarAriaLabel; // State variables. /** @type {?HTMLElement} */ this._navBarEl = $element[0]; /** @type {?JQLite} */ this._inkbar; var self = this; // need to wait for transcluded content to be available var deregisterTabWatch = this._$scope.$watch(function() { return self._navBarEl.querySelectorAll('._md-nav-button').length; }, function(newLength) { if (newLength > 0) { self._initTabs(); deregisterTabWatch(); } }); } /** * Initializes the tab components once they exist. * @private */ MdNavBarController.prototype._initTabs = function() { this._inkbar = angular.element(this._navBarEl.querySelector('md-nav-ink-bar')); var self = this; this._$timeout(function() { self._updateTabs(self.mdSelectedNavItem, null); }); this._$scope.$watch('ctrl.mdSelectedNavItem', function(newValue, oldValue) { // Wait a digest before update tabs for products doing // anything dynamic in the template. self._$timeout(function() { self._updateTabs(newValue, oldValue); }); }); }; /** * Set the current tab to be selected. * @param {string|undefined} newValue New current tab name. * @param {string|undefined|null} oldValue Previous tab name. * @private */ MdNavBarController.prototype._updateTabs = function(newValue, oldValue) { var self = this; var tabs = this._getTabs(); var sameTab = newValue === oldValue; // this._getTabs can return null if nav-bar has not yet been initialized if (!tabs) return; var newIndex = -1; var newTab = this._getTabByName(newValue); var oldTab = this._getTabByName(oldValue); if (oldTab) { oldTab.setSelected(false); } if (newTab) { newTab.setSelected(true); newIndex = tabs.indexOf(newTab); } this._$timeout(function() { self._updateInkBarStyles(newTab, newIndex); // Don't change focus when there is no newTab, the new and old tabs are the same, or when // called from MdNavBarController._initTabs() which would have no oldTab defined. if (newTab && oldTab && !sameTab) { self._moveFocus(oldTab, newTab); } }); }; /** * Repositions the ink bar to the selected tab. * @param {MdNavItemController} tab the nav item that should have ink bar styles applied * @param {number=} newIndex the index of the newly selected nav item * @private */ MdNavBarController.prototype._updateInkBarStyles = function(tab, newIndex) { this._inkbar.css({display: newIndex < 0 ? 'none' : ''}); if (tab) { var tabEl = tab.getButtonEl(); var left = tabEl.offsetLeft; var tabWidth = tabEl.offsetWidth; var navBarWidth = this._navBarEl.getBoundingClientRect().width; var scale = tabWidth / navBarWidth; var translate = left / navBarWidth * 100; this._inkbar.css({ transform: 'translateX(' + translate + '%) scaleX(' + scale + ')' }); } }; /** * Updates ink bar to match current tab. */ MdNavBarController.prototype.updateSelectedTabInkBar = function() { this._updateInkBarStyles(this._getSelectedTab()); }; /** * Returns an array of the current tabs. * @return {Array<!MdNavItemController>} * @private */ MdNavBarController.prototype._getTabs = function() { var controllers = Array.prototype.slice.call( this._navBarEl.querySelectorAll('.md-nav-item')) .map(function(el) { return angular.element(el).controller('mdNavItem'); }); return controllers.indexOf(undefined) ? controllers : []; }; /** * Returns the tab with the specified name. * @param {string} name The name of the tab, found in its name attribute. * @return {MdNavItemController} * @private */ MdNavBarController.prototype._getTabByName = function(name) { return this._findTab(function(tab) { return tab.getName() === name; }); }; /** * Returns the selected tab. * @return {MdNavItemController} * @private */ MdNavBarController.prototype._getSelectedTab = function() { return this._findTab(function(tab) { return tab.isSelected(); }); }; /** * Returns the focused tab. * @return {MdNavItemController} */ MdNavBarController.prototype.getFocusedTab = function() { return this._findTab(function(tab) { return tab.hasFocus(); }); }; /** * Find a tab that matches the specified function, starting from the first tab. * @param {Function} fn * @param {number=} startIndex index to start at. Defaults to 0. * @returns {MdNavItemController|null} * @private */ MdNavBarController.prototype._findTab = function(fn, startIndex) { var tabs = this._getTabs(), i; if (startIndex == null) { startIndex = 0; } for (i = startIndex; i < tabs.length; i++) { if (fn(tabs[i])) { return tabs[i]; } } return null; }; /** * Find a tab that matches the specified function, going backwards from the end of the list. * @param {Function} fn * @param {number=} startIndex index to start at. Defaults to tabs.length - 1. * @returns {MdNavItemController} * @private */ MdNavBarController.prototype._findTabReverse = function(fn, startIndex) { var tabs = this._getTabs(); if (startIndex === undefined || startIndex === null) { startIndex = tabs.length - 1; } for (var i = startIndex; i >= 0 ; i--) { if (fn(tabs[i])) { return tabs[i]; } } return null; }; /** * Direct focus to the selected tab when focus enters the nav bar. */ MdNavBarController.prototype.onFocus = function() { var tab = this._getSelectedTab(); if (tab && !tab.isFocused) { tab.setFocused(true); } }; /** * Move focus from oldTab to newTab. * @param {!MdNavItemController} oldTab * @param {!MdNavItemController} newTab * @private */ MdNavBarController.prototype._moveFocus = function(oldTab, newTab) { oldTab.setFocused(false); newTab.setFocused(true); }; /** * Focus the first tab. * @private */ MdNavBarController.prototype._focusFirstTab = function() { var tabs = this._getTabs(); if (!tabs) return; var tabToFocus = this._findTab(function(tab) { return tab._isEnabled(); }); if (tabToFocus) { this._moveFocus(this.getFocusedTab(), tabToFocus); } }; /** * Focus the last tab. * @private */ MdNavBarController.prototype._focusLastTab = function() { var tabs = this._getTabs(); if (!tabs) return; var tabToFocus = this._findTabReverse(function(tab) { return tab._isEnabled(); }); if (tabToFocus) { this._moveFocus(this.getFocusedTab(), tabToFocus); } }; /** * Focus the next non-disabled tab. * @param {number} focusedTabIndex the index of the currently focused tab * @private */ MdNavBarController.prototype._focusNextTab = function(focusedTabIndex) { var tabs = this._getTabs(); if (!tabs) return; var tabToFocus = this._findTab(function(tab) { return tab._isEnabled(); }, focusedTabIndex + 1); if (tabToFocus) { this._moveFocus(this.getFocusedTab(), tabToFocus); } else { this._focusFirstTab(); } }; /** * Focus the previous non-disabled tab. * @param {number} focusedTabIndex the index of the currently focused tab * @private */ MdNavBarController.prototype._focusPreviousTab = function(focusedTabIndex) { var tabs = this._getTabs(); if (!tabs) return; var tabToFocus = this._findTabReverse(function(tab) { return tab._isEnabled(); }, focusedTabIndex - 1); if (tabToFocus) { this._moveFocus(this.getFocusedTab(), tabToFocus); } else { this._focusLastTab(); } }; /** * Responds to keydown events. * Calls to preventDefault() stop the page from scrolling when changing focus in the nav-bar. * @param {!KeyboardEvent} e */ MdNavBarController.prototype.onKeydown = function(e) { var keyCodes = this._$mdConstant.KEY_CODE; var tabs = this._getTabs(); var focusedTab = this.getFocusedTab(); if (!focusedTab || !tabs) return; var focusedTabIndex = tabs.indexOf(focusedTab); // use arrow keys to navigate between tabs switch (e.keyCode) { case keyCodes.UP_ARROW: case keyCodes.LEFT_ARROW: e.preventDefault(); this._focusPreviousTab(focusedTabIndex); break; case keyCodes.DOWN_ARROW: case keyCodes.RIGHT_ARROW: e.preventDefault(); this._focusNextTab(focusedTabIndex); break; case keyCodes.SPACE: case keyCodes.ENTER: // timeout to avoid a "digest already in progress" console error this._$timeout(function() { focusedTab.getButtonEl().click(); }); break; case keyCodes.HOME: e.preventDefault(); this._focusFirstTab(); break; case keyCodes.END: e.preventDefault(); this._focusLastTab(); break; } }; /** * @ngdoc directive * @name mdNavItem * @module material.components.navBar * * @restrict E * * @description * `<md-nav-item>` describes a page navigation link within the `<md-nav-bar>` component. * It renders an `<md-button>` as the actual link. * * Exactly one of the `md-nav-click`, `md-nav-href`, or `md-nav-sref` attributes are required * to be specified. * * @param {string=} nav-item-aria-label Allows setting or overriding the label that is announced by * a screen reader for the nav item's button. If this is not set, the nav item's transcluded * content will be announced. Make sure to set this if the nav item's transcluded content does * not include descriptive text, for example only an icon. * @param {expression=} md-nav-click Expression which will be evaluated when the * link is clicked to change the page. Renders as an `ng-click`. * @param {string=} md-nav-href url to transition to when this link is clicked. * Renders as an `ng-href`. * @param {string=} md-nav-sref UI-Router state to transition to when this link is * clicked. Renders as a `ui-sref`. * @param {string} name The name of this link. Used by the nav bar to know * which link is currently selected. * @param {!object=} sref-opts UI-Router options that are passed to the `$state.go()` function. See * the <a ng-href="https://ui-router.github.io/docs/latest/interfaces/transition.transitionoptions.html" * target="_blank">UI-Router documentation for details</a>. * * @usage * See <a ng-href="api/directive/mdNavBar">md-nav-bar</a> for usage. */ /** * @param $mdAria * @param $$rAF * @param $mdUtil * @param $window * @constructor * ngInject */ function MdNavItem($mdAria, $$rAF, $mdUtil, $window) { return { restrict: 'E', require: ['mdNavItem', '^mdNavBar'], controller: MdNavItemController, bindToController: true, controllerAs: 'ctrl', replace: true, transclude: true, template: function(tElement, tAttrs) { var hasNavClick = tAttrs.mdNavClick; var hasNavHref = tAttrs.mdNavHref; var hasNavSref = tAttrs.mdNavSref; var hasSrefOpts = tAttrs.srefOpts; var navigationAttribute; var navigationOptions; var buttonTemplate; // Cannot specify more than one nav attribute if ((hasNavClick ? 1 : 0) + (hasNavHref ? 1 : 0) + (hasNavSref ? 1 : 0) > 1) { throw Error( 'Please do not specify more than one of the md-nav-click, md-nav-href, ' + 'or md-nav-sref attributes per nav-item directive.' ); } if (hasNavClick !== undefined && hasNavClick !== null) { navigationAttribute = 'ng-click="ctrl.mdNavClick()"'; } else if (hasNavHref !== undefined && hasNavHref !== null) { navigationAttribute = 'ng-href="{{ctrl.mdNavHref}}"'; } else if (hasNavSref !== undefined && hasNavSref !== null) { navigationAttribute = 'ui-sref="{{ctrl.mdNavSref}}"'; } else { throw Error( 'Please specify at least one of the md-nav-click, md-nav-href, or md-nav-sref ' + 'attributes per nav-item directive.'); } navigationOptions = hasSrefOpts ? 'ui-sref-opts="{{ctrl.srefOpts}}" ' : ''; if (navigationAttribute) { buttonTemplate = '' + '<md-button class="_md-nav-button md-accent" ' + 'ng-class="ctrl.getNgClassMap()" ' + 'ng-blur="ctrl.setFocused(false)" ' + 'ng-disabled="ctrl.disabled" ' + 'tabindex="-1" ' + 'role="tab" ' + 'ng-attr-aria-label="{{ctrl.navItemAriaLabel ? ctrl.navItemAriaLabel : undefined}}" ' + 'aria-selected="{{ctrl.isSelected()}}" ' + navigationOptions + navigationAttribute + '>' + '<span ng-transclude class="_md-nav-button-text"></span>' + '</md-button>'; } return '' + '<li class="md-nav-item" ' + 'role="presentation">' + (buttonTemplate || '') + '</li>'; }, scope: { 'mdNavClick': '&?', 'mdNavHref': '@?', 'mdNavSref': '@?', 'srefOpts': '=?', 'name': '@', 'navItemAriaLabel': '@?', }, link: function(scope, element, attrs, controllers) { var disconnect; var mdNavItem; var mdNavBar; var navButton; // When accessing the element's contents synchronously, they // may not be defined yet because of transclusion. There is a higher // chance that it will be accessible if we wait one frame. $$rAF(function() { mdNavItem = controllers[0]; mdNavBar = controllers[1]; navButton = angular.element(element[0].querySelector('._md-nav-button')); if (!mdNavItem.name) { mdNavItem.name = angular.element(element[0] .querySelector('._md-nav-button-text')).text().trim(); } navButton.on('keydown', function($event) { mdNavBar.onKeydown($event); }); navButton.on('focus', function() { mdNavItem._focused = true; }); navButton.on('click', function() { // This triggers a watcher on mdNavBar.mdSelectedNavItem which calls // MdNavBarController._updateTabs() after a $timeout. That function calls // MdNavItemController.setSelected() for the old tab with false and the new tab with true. mdNavBar.mdSelectedNavItem = mdNavItem.name; scope.$apply(); }); // Get the disabled attribute value first, then setup observing of value changes mdNavItem.disabled = $mdUtil.parseAttributeBoolean(attrs['disabled'], false); if ('MutationObserver' in $window) { var config = {attributes: true, attributeFilter: ['disabled']}; var targetNode = element[0]; var mutationCallback = function(mutationList) { $mdUtil.nextTick(function() { mdNavItem.disabled = $mdUtil.parseAttributeBoolean(attrs[mutationList[0].attributeName], false); }); }; var observer = new MutationObserver(mutationCallback); observer.observe(targetNode, config); disconnect = observer.disconnect.bind(observer); } else { attrs.$observe('disabled', function (value) { mdNavItem.disabled = $mdUtil.parseAttributeBoolean(value, false); }); } if (!mdNavItem.navItemAriaLabel) { $mdAria.expectWithText(navButton, 'aria-label'); } }); scope.$on('destroy', function() { navButton.off('keydown'); navButton.off('focus'); navButton.off('click'); disconnect(); }); } }; } /** * Controller for the nav-item component. * @param {!JQLite} $element * @constructor * @final * ngInject */ function MdNavItemController($element) { /** * @private @const * @type {!JQLite} */ this._$element = $element; // Data-bound variables /** * @const * @type {?Function} */ this.mdNavClick; /** * @const * @type {?string} */ this.mdNavHref; /** * @const * @type {?string} */ this.mdNavSref; /** * @const * @type {?Object} */ this.srefOpts; /** * @const * @type {?string} */ this.name; /** * @const * @type {string} */ this.navItemAriaLabel; // State variables /** * @private * @type {boolean} */ this._selected = false; /** * @type {boolean} */ this.isFocused = false; } /** * Returns a map of class names and values for use by ng-class. * @return {!Object<string,boolean>} */ MdNavItemController.prototype.getNgClassMap = function() { return { 'md-active': this._selected, 'md-primary': this._selected, 'md-unselected': !this._selected, 'md-focused': this.isFocused, }; }; /** * Get the name attribute of the tab. * @return {string} */ MdNavItemController.prototype.getName = function() { return this.name; }; /** * Get the button element associated with the tab. * @return {!Element} */ MdNavItemController.prototype.getButtonEl = function() { return this._$element[0].querySelector('._md-nav-button'); }; /** * Set the selected state of the tab and updates the tabindex. * This function is called for the oldTab and newTab when selection changes. * @param {boolean} isSelected true to select the tab, false to deselect the tab */ MdNavItemController.prototype.setSelected = function(isSelected) { this._selected = isSelected; if (isSelected) { // https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-2/tabs.html suggests that we call // removeAttribute('tabindex') here, but that causes our unit tests to fail due to // document.activeElement staying set to the body instead of the focused nav button. this.getButtonEl().setAttribute('tabindex', '0'); } else { this.getButtonEl().setAttribute('tabindex', '-1'); } }; /** * @return {boolean} */ MdNavItemController.prototype.isSelected = function() { return this._selected; }; /** * Set the focused state of the tab. * @param {boolean} isFocused */ MdNavItemController.prototype.setFocused = function(isFocused) { this.isFocused = isFocused; if (isFocused) { this.getButtonEl().focus(); } }; /** * @return {boolean} true if the tab has focus, false if not. */ MdNavItemController.prototype.hasFocus = function() { return this.isFocused; }; /** * @return {boolean} true if the tab is enabled, false if disabled. * @private */ MdNavItemController.prototype._isEnabled = function() { return !this._$element.attr('disabled'); }; })(window, window.angular);