From 60069e67aeb0352a7849ebfef15a6183bc3b7235 Mon Sep 17 00:00:00 2001 From: Georgios Kalpakas Date: Sun, 7 Aug 2016 17:34:14 +0300 Subject: [PATCH 1/5] feat($route): add support for the `reloadOnUrl` configuration option Enables users to specify that a particular route should not be reloaded after a URL change (including a change in `$location.path()`), if the new URL maps to the same route. The default behavior is still to always load the matched route when any part of the URL changes. Related to #1699, #5860, #14999 (potentially closing the first two). Fixes #7925 Closes #15002 --- src/ngRoute/route.js | 50 ++++- test/ngRoute/routeSpec.js | 424 +++++++++++++++++++++++++++++++++----- 2 files changed, 421 insertions(+), 53 deletions(-) diff --git a/src/ngRoute/route.js b/src/ngRoute/route.js index f0e6c19b9079..6902a3e654d2 100644 --- a/src/ngRoute/route.js +++ b/src/ngRoute/route.js @@ -183,11 +183,22 @@ function $RouteProvider() { * `redirectTo` takes precedence over `resolveRedirectTo`, so specifying both on the same * route definition, will cause the latter to be ignored. * + * - `[reloadOnUrl=true]` - `{boolean=}` - reload route when any part of the URL changes + * (inluding the path) even if the new URL maps to the same route. + * + * If the option is set to `false` and the URL in the browser changes, but the new URL maps + * to the same route, then a `$routeUpdate` event is broadcasted on the root scope (without + * reloading the route). + * * - `[reloadOnSearch=true]` - `{boolean=}` - reload route when only `$location.search()` * or `$location.hash()` changes. * - * If the option is set to `false` and url in the browser changes, then - * `$routeUpdate` event is broadcasted on the root scope. + * If the option is set to `false` and the URL in the browser changes, then a `$routeUpdate` + * event is broadcasted on the root scope (without reloading the route). + * + *
+ * **Note:** This option has no effect if `reloadOnUrl` is set to `false`. + *
* * - `[caseInsensitiveMatch=false]` - `{boolean=}` - match routes without being case sensitive * @@ -202,6 +213,9 @@ function $RouteProvider() { this.when = function(path, route) { //copy original route object to preserve params inherited from proto chain var routeCopy = shallowCopy(route); + if (angular.isUndefined(routeCopy.reloadOnUrl)) { + routeCopy.reloadOnUrl = true; + } if (angular.isUndefined(routeCopy.reloadOnSearch)) { routeCopy.reloadOnSearch = true; } @@ -544,8 +558,9 @@ function $RouteProvider() { * @name $route#$routeUpdate * @eventType broadcast on root scope * @description - * The `reloadOnSearch` property has been set to false, and we are reusing the same - * instance of the Controller. + * Broadcasted if the same instance of a route (including template, controller instance, + * resolved dependencies, etc.) is being reused. This can happen if either `reloadOnSearch` or + * `reloadOnUrl` has been set to `false`. * * @param {Object} angularEvent Synthetic event object * @param {Route} current Current/previous route information. @@ -653,9 +668,7 @@ function $RouteProvider() { var lastRoute = $route.current; preparedRoute = parseRoute(); - preparedRouteIsUpdateOnly = preparedRoute && lastRoute && preparedRoute.$$route === lastRoute.$$route - && angular.equals(preparedRoute.pathParams, lastRoute.pathParams) - && !preparedRoute.reloadOnSearch && !forceReload; + preparedRouteIsUpdateOnly = isNavigationUpdateOnly(preparedRoute, lastRoute); if (!preparedRouteIsUpdateOnly && (lastRoute || preparedRoute)) { if ($rootScope.$broadcast('$routeChangeStart', preparedRoute, lastRoute).defaultPrevented) { @@ -835,6 +848,29 @@ function $RouteProvider() { return match || routes[null] && inherit(routes[null], {params: {}, pathParams:{}}); } + /** + * @param {Object} newRoute - The new route configuration (as returned by `parseRoute()`). + * @param {Object} oldRoute - The previous route configuration (as returned by `parseRoute()`). + * @returns {boolean} Whether this is an "update-only" navigation, i.e. the URL maps to the same + * route and it can be reused (based on the config and the type of change). + */ + function isNavigationUpdateOnly(newRoute, oldRoute) { + // IF this is not a forced reload + return !forceReload + // AND both `newRoute`/`oldRoute` are defined + && newRoute && oldRoute + // AND they map to the same Route Definition Object + && (newRoute.$$route === oldRoute.$$route) + // AND `reloadOnUrl` is disabled + && (!newRoute.reloadOnUrl + // OR `reloadOnSearch` is disabled + || (!newRoute.reloadOnSearch + // AND both routes have the same path params + && angular.equals(newRoute.pathParams, oldRoute.pathParams) + ) + ); + } + /** * @returns {string} interpolation of the redirect path with the parameters */ diff --git a/test/ngRoute/routeSpec.js b/test/ngRoute/routeSpec.js index 36832ab57884..14d655af83e9 100644 --- a/test/ngRoute/routeSpec.js +++ b/test/ngRoute/routeSpec.js @@ -65,8 +65,8 @@ describe('$route', function() { $httpBackend.when('GET', 'Chapter.html').respond('chapter'); $httpBackend.when('GET', 'test.html').respond('test'); $httpBackend.when('GET', 'foo.html').respond('foo'); - $httpBackend.when('GET', 'baz.html').respond('baz'); $httpBackend.when('GET', 'bar.html').respond('bar'); + $httpBackend.when('GET', 'baz.html').respond('baz'); $httpBackend.when('GET', 'http://example.com/trusted-template.html').respond('cross domain trusted template'); $httpBackend.when('GET', '404.html').respond('not found'); }; @@ -76,6 +76,7 @@ describe('$route', function() { dealoc(element); }); + it('should allow cancellation via $locationChangeStart via $routeChangeStart', function() { module(function($routeProvider) { $routeProvider.when('/Edit', { @@ -1677,95 +1678,413 @@ describe('$route', function() { }); - describe('reloadOnSearch', function() { - it('should reload a route when reloadOnSearch is enabled and .search() changes', function() { - var reloaded = jasmine.createSpy('route reload'); + describe('reloadOnUrl', function() { + it('should reload when `reloadOnUrl` is true and `.url()` changes', function() { + var routeChange = jasmine.createSpy('routeChange'); module(function($routeProvider) { - $routeProvider.when('/foo', {controller: angular.noop}); + $routeProvider.when('/path/:param', {}); }); - inject(function($route, $location, $rootScope, $routeParams) { - $rootScope.$on('$routeChangeStart', reloaded); - $location.path('/foo'); + inject(function($location, $rootScope, $routeParams) { + $rootScope.$on('$routeChangeStart', routeChange); + + // Initial load + $location.path('/path/foo'); $rootScope.$digest(); - expect(reloaded).toHaveBeenCalled(); - expect($routeParams).toEqual({}); - reloaded.calls.reset(); + expect(routeChange).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({param: 'foo'}); - // trigger reload - $location.search({foo: 'bar'}); + routeChange.calls.reset(); + + // Reload on `path` change + $location.path('/path/bar'); $rootScope.$digest(); - expect(reloaded).toHaveBeenCalled(); - expect($routeParams).toEqual({foo:'bar'}); + expect(routeChange).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({param: 'bar'}); + + routeChange.calls.reset(); + + // Reload on `search` change + $location.search('foo', 'bar'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({param: 'bar', foo: 'bar'}); + + routeChange.calls.reset(); + + // Reload on `hash` change + $location.hash('baz'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({param: 'bar', foo: 'bar'}); }); }); - it('should not reload a route when reloadOnSearch is disabled and only .search() changes', function() { - var routeChange = jasmine.createSpy('route change'), - routeUpdate = jasmine.createSpy('route update'); + it('should reload when `reloadOnUrl` is false and URL maps to different route', + function() { + var routeChange = jasmine.createSpy('routeChange'); + var routeUpdate = jasmine.createSpy('routeUpdate'); + + module(function($routeProvider) { + $routeProvider. + when('/path/:param', {reloadOnUrl: false}). + otherwise({}); + }); + + inject(function($location, $rootScope, $routeParams) { + $rootScope.$on('$routeChangeStart', routeChange); + $rootScope.$on('$routeChangeSuccess', routeChange); + $rootScope.$on('$routeUpdate', routeUpdate); + + expect(routeChange).not.toHaveBeenCalled(); + + // Initial load + $location.path('/path/foo'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledTimes(2); + expect(routeUpdate).not.toHaveBeenCalled(); + expect($routeParams).toEqual({param: 'foo'}); + + routeChange.calls.reset(); + + // Route change + $location.path('/other/path/bar'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledTimes(2); + expect(routeUpdate).not.toHaveBeenCalled(); + expect($routeParams).toEqual({}); + }); + } + ); + + + it('should not reload when `reloadOnUrl` is false and URL maps to the same route', + function() { + var routeChange = jasmine.createSpy('routeChange'); + var routeUpdate = jasmine.createSpy('routeUpdate'); + + module(function($routeProvider) { + $routeProvider.when('/path/:param', {reloadOnUrl: false}); + }); + + inject(function($location, $rootScope, $routeParams) { + $rootScope.$on('$routeChangeStart', routeChange); + $rootScope.$on('$routeChangeSuccess', routeChange); + $rootScope.$on('$routeUpdate', routeUpdate); + + expect(routeChange).not.toHaveBeenCalled(); + + // Initial load + $location.path('/path/foo'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledTimes(2); + expect(routeUpdate).not.toHaveBeenCalled(); + expect($routeParams).toEqual({param: 'foo'}); + + routeChange.calls.reset(); + + // Route update (no reload) + $location.path('/path/bar').search('foo', 'bar').hash('baz'); + $rootScope.$digest(); + expect(routeChange).not.toHaveBeenCalled(); + expect(routeUpdate).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({param: 'bar', foo: 'bar'}); + }); + } + ); + + + it('should update `$routeParams` even when not reloading a route', function() { + var routeChange = jasmine.createSpy('routeChange'); module(function($routeProvider) { - $routeProvider.when('/foo', {controller: angular.noop, reloadOnSearch: false}); + $routeProvider.when('/path/:param', {reloadOnUrl: false}); }); - inject(function($route, $location, $rootScope) { + inject(function($location, $rootScope, $routeParams) { $rootScope.$on('$routeChangeStart', routeChange); $rootScope.$on('$routeChangeSuccess', routeChange); - $rootScope.$on('$routeUpdate', routeUpdate); expect(routeChange).not.toHaveBeenCalled(); - $location.path('/foo'); + // Initial load + $location.path('/path/foo'); $rootScope.$digest(); - expect(routeChange).toHaveBeenCalled(); expect(routeChange).toHaveBeenCalledTimes(2); - expect(routeUpdate).not.toHaveBeenCalled(); + expect($routeParams).toEqual({param: 'foo'}); + routeChange.calls.reset(); - // don't trigger reload - $location.search({foo: 'bar'}); + // Route update (no reload) + $location.path('/path/bar'); $rootScope.$digest(); expect(routeChange).not.toHaveBeenCalled(); - expect(routeUpdate).toHaveBeenCalled(); + expect($routeParams).toEqual({param: 'bar'}); }); }); - it('should reload reloadOnSearch route when url differs only in route path param', function() { - var routeChange = jasmine.createSpy('route change'); + describe('with `$route.reload()`', function() { + var $location; + var $log; + var $rootScope; + var $route; + var routeChangeStart; + var routeChangeSuccess; - module(function($routeProvider) { - $routeProvider.when('/foo/:fooId', {controller: angular.noop, reloadOnSearch: false}); + beforeEach(module(function($routeProvider) { + $routeProvider.when('/path/:param', { + template: '', + reloadOnUrl: false, + controller: function Controller($log) { + $log.debug('initialized'); + } + }); + })); + + beforeEach(inject(function($compile, _$location_, _$log_, _$rootScope_, _$route_) { + $location = _$location_; + $log = _$log_; + $rootScope = _$rootScope_; + $route = _$route_; + + routeChangeStart = jasmine.createSpy('routeChangeStart'); + routeChangeSuccess = jasmine.createSpy('routeChangeSuccess'); + + $rootScope.$on('$routeChangeStart', routeChangeStart); + $rootScope.$on('$routeChangeSuccess', routeChangeSuccess); + + element = $compile('
')($rootScope); + })); + + + it('should reload the current route', function() { + $location.path('/path/foo'); + $rootScope.$digest(); + expect($location.path()).toBe('/path/foo'); + expect(routeChangeStart).toHaveBeenCalledOnce(); + expect(routeChangeSuccess).toHaveBeenCalledOnce(); + expect($log.debug.logs).toEqual([['initialized']]); + + routeChangeStart.calls.reset(); + routeChangeSuccess.calls.reset(); + $log.reset(); + + $route.reload(); + $rootScope.$digest(); + expect($location.path()).toBe('/path/foo'); + expect(routeChangeStart).toHaveBeenCalledOnce(); + expect(routeChangeSuccess).toHaveBeenCalledOnce(); + expect($log.debug.logs).toEqual([['initialized']]); + + $log.reset(); }); - inject(function($route, $location, $rootScope) { - $rootScope.$on('$routeChangeStart', routeChange); - $rootScope.$on('$routeChangeSuccess', routeChange); - expect(routeChange).not.toHaveBeenCalled(); + it('should support preventing a route reload', function() { + $location.path('/path/foo'); + $rootScope.$digest(); + expect($location.path()).toBe('/path/foo'); + expect(routeChangeStart).toHaveBeenCalledOnce(); + expect(routeChangeSuccess).toHaveBeenCalledOnce(); + expect($log.debug.logs).toEqual([['initialized']]); + + routeChangeStart.calls.reset(); + routeChangeSuccess.calls.reset(); + $log.reset(); - $location.path('/foo/aaa'); + routeChangeStart.and.callFake(function(evt) { evt.preventDefault(); }); + + $route.reload(); $rootScope.$digest(); - expect(routeChange).toHaveBeenCalled(); - expect(routeChange).toHaveBeenCalledTimes(2); - routeChange.calls.reset(); + expect($location.path()).toBe('/path/foo'); + expect(routeChangeStart).toHaveBeenCalledOnce(); + expect(routeChangeSuccess).not.toHaveBeenCalled(); + expect($log.debug.logs).toEqual([]); + }); + + + it('should reload the current route even if `reloadOnUrl` is disabled', + inject(function($routeParams) { + $location.path('/path/foo'); + $rootScope.$digest(); + expect(routeChangeStart).toHaveBeenCalledOnce(); + expect(routeChangeSuccess).toHaveBeenCalledOnce(); + expect($log.debug.logs).toEqual([['initialized']]); + expect($routeParams).toEqual({param: 'foo'}); + + routeChangeStart.calls.reset(); + routeChangeSuccess.calls.reset(); + $log.reset(); + + $location.path('/path/bar'); + $rootScope.$digest(); + expect(routeChangeStart).not.toHaveBeenCalled(); + expect(routeChangeSuccess).not.toHaveBeenCalled(); + expect($log.debug.logs).toEqual([]); + expect($routeParams).toEqual({param: 'bar'}); + + $route.reload(); + $rootScope.$digest(); + expect(routeChangeStart).toHaveBeenCalledOnce(); + expect(routeChangeSuccess).toHaveBeenCalledOnce(); + expect($log.debug.logs).toEqual([['initialized']]); + expect($routeParams).toEqual({param: 'bar'}); + + $log.reset(); + }) + ); + }); + }); + + describe('reloadOnSearch', function() { + it('should not have any effect if `reloadOnUrl` is false', function() { + var reloaded = jasmine.createSpy('route reload'); + + module(function($routeProvider) { + $routeProvider.when('/foo', { + reloadOnUrl: false, + reloadOnSearch: true + }); + }); + + inject(function($route, $location, $rootScope, $routeParams) { + $rootScope.$on('$routeChangeStart', reloaded); - $location.path('/foo/bbb'); + $location.path('/foo'); $rootScope.$digest(); - expect(routeChange).toHaveBeenCalled(); - expect(routeChange).toHaveBeenCalledTimes(2); - routeChange.calls.reset(); + expect(reloaded).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({}); + reloaded.calls.reset(); + + // trigger reload (via .search()) $location.search({foo: 'bar'}); $rootScope.$digest(); - expect(routeChange).not.toHaveBeenCalled(); + expect(reloaded).not.toHaveBeenCalled(); + expect($routeParams).toEqual({foo: 'bar'}); + + // trigger reload (via .hash()) + $location.hash('baz'); + $rootScope.$digest(); + expect(reloaded).not.toHaveBeenCalled(); + expect($routeParams).toEqual({foo: 'bar'}); }); }); - it('should update params when reloadOnSearch is disabled and .search() changes', function() { + it('should reload when `reloadOnSearch` is true and `.search()`/`.hash()` changes', + function() { + var reloaded = jasmine.createSpy('route reload'); + + module(function($routeProvider) { + $routeProvider.when('/foo', {controller: angular.noop}); + }); + + inject(function($route, $location, $rootScope, $routeParams) { + $rootScope.$on('$routeChangeStart', reloaded); + + $location.path('/foo'); + $rootScope.$digest(); + expect(reloaded).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({}); + + reloaded.calls.reset(); + + // trigger reload (via .search()) + $location.search({foo: 'bar'}); + $rootScope.$digest(); + expect(reloaded).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({foo: 'bar'}); + + reloaded.calls.reset(); + + // trigger reload (via .hash()) + $location.hash('baz'); + $rootScope.$digest(); + expect(reloaded).toHaveBeenCalledOnce(); + expect($routeParams).toEqual({foo: 'bar'}); + }); + } + ); + + + it('should not reload when `reloadOnSearch` is false and `.search()`/`.hash()` changes', + function() { + var routeChange = jasmine.createSpy('route change'), + routeUpdate = jasmine.createSpy('route update'); + + module(function($routeProvider) { + $routeProvider.when('/foo', {controller: angular.noop, reloadOnSearch: false}); + }); + + inject(function($route, $location, $rootScope) { + $rootScope.$on('$routeChangeStart', routeChange); + $rootScope.$on('$routeChangeSuccess', routeChange); + $rootScope.$on('$routeUpdate', routeUpdate); + + expect(routeChange).not.toHaveBeenCalled(); + + $location.path('/foo'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledTimes(2); + expect(routeUpdate).not.toHaveBeenCalled(); + + routeChange.calls.reset(); + + // don't trigger reload (via .search()) + $location.search({foo: 'bar'}); + $rootScope.$digest(); + expect(routeChange).not.toHaveBeenCalled(); + expect(routeUpdate).toHaveBeenCalledOnce(); + + routeUpdate.calls.reset(); + + // don't trigger reload (via .hash()) + $location.hash('baz'); + $rootScope.$digest(); + expect(routeChange).not.toHaveBeenCalled(); + expect(routeUpdate).toHaveBeenCalled(); + }); + } + ); + + + it('should reload when `reloadOnSearch` is false and url differs only in route path param', + function() { + var routeChange = jasmine.createSpy('route change'); + + module(function($routeProvider) { + $routeProvider.when('/foo/:fooId', {controller: angular.noop, reloadOnSearch: false}); + }); + + inject(function($route, $location, $rootScope) { + $rootScope.$on('$routeChangeStart', routeChange); + $rootScope.$on('$routeChangeSuccess', routeChange); + + expect(routeChange).not.toHaveBeenCalled(); + + $location.path('/foo/aaa'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledTimes(2); + routeChange.calls.reset(); + + $location.path('/foo/bbb'); + $rootScope.$digest(); + expect(routeChange).toHaveBeenCalledTimes(2); + routeChange.calls.reset(); + + $location.search({foo: 'bar'}).hash('baz'); + $rootScope.$digest(); + expect(routeChange).not.toHaveBeenCalled(); + }); + } + ); + + + it('should update params when `reloadOnSearch` is false and `.search()` changes', function() { var routeParamsWatcher = jasmine.createSpy('routeParamsWatcher'); module(function($routeProvider) { @@ -1852,7 +2171,8 @@ describe('$route', function() { }); }); - describe('reload', function() { + + describe('with `$route.reload()`', function() { var $location; var $log; var $rootScope; @@ -1886,6 +2206,7 @@ describe('$route', function() { element = $compile('
')($rootScope); })); + it('should reload the current route', function() { $location.path('/bar/123'); $rootScope.$digest(); @@ -1908,6 +2229,7 @@ describe('$route', function() { $log.reset(); }); + it('should support preventing a route reload', function() { $location.path('/bar/123'); $rootScope.$digest(); @@ -1930,6 +2252,7 @@ describe('$route', function() { expect($log.debug.logs).toEqual([]); }); + it('should reload even if reloadOnSearch is false', inject(function($routeParams) { $location.path('/bar/123'); $rootScope.$digest(); @@ -1946,6 +2269,15 @@ describe('$route', function() { expect(routeChangeSuccessSpy).not.toHaveBeenCalled(); expect($log.debug.logs).toEqual([]); + routeChangeSuccessSpy.calls.reset(); + $log.reset(); + + $location.hash('c'); + $rootScope.$digest(); + expect($routeParams).toEqual({barId: '123', a: 'b'}); + expect(routeChangeSuccessSpy).not.toHaveBeenCalled(); + expect($log.debug.logs).toEqual([]); + $route.reload(); $rootScope.$digest(); expect($routeParams).toEqual({barId: '123', a: 'b'}); From 83d1229c87d41954a5446073fbae1c779439f262 Mon Sep 17 00:00:00 2001 From: Georgios Kalpakas Date: Sun, 26 Jun 2016 22:24:57 +0300 Subject: [PATCH 2/5] refactor(ngAria): move test helpers inside of closure --- test/ngAria/ariaSpec.js | 50 ++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/test/ngAria/ariaSpec.js b/test/ngAria/ariaSpec.js index 201608498961..58cd6584157f 100644 --- a/test/ngAria/ariaSpec.js +++ b/test/ngAria/ariaSpec.js @@ -9,18 +9,6 @@ describe('$aria', function() { dealoc(element); }); - function injectScopeAndCompiler() { - return inject(function(_$compile_, _$rootScope_) { - $compile = _$compile_; - scope = _$rootScope_; - }); - } - - function compileElement(inputHtml) { - element = $compile(inputHtml)(scope); - scope.$digest(); - } - describe('aria-hidden', function() { beforeEach(injectScopeAndCompiler); @@ -895,19 +883,31 @@ describe('$aria', function() { expect(element.attr('tabindex')).toBe('0'); }); }); -}); -function expectAriaAttrOnEachElement(elem, ariaAttr, expected) { - angular.forEach(elem, function(val) { - expect(angular.element(val).attr(ariaAttr)).toBe(expected); - }); -} + // Helpers + function compileElement(inputHtml) { + element = $compile(inputHtml)(scope); + scope.$digest(); + } + + function configAriaProvider(config) { + return function() { + module(function($ariaProvider) { + $ariaProvider.config(config); + }); + }; + } -function configAriaProvider(config) { - return function() { - angular.module('ariaTest', ['ngAria']).config(function($ariaProvider) { - $ariaProvider.config(config); + function expectAriaAttrOnEachElement(elem, ariaAttr, expected) { + angular.forEach(elem, function(val) { + expect(angular.element(val).attr(ariaAttr)).toBe(expected); }); - module('ariaTest'); - }; -} + } + + function injectScopeAndCompiler() { + return inject(function(_$compile_, _$rootScope_) { + $compile = _$compile_; + scope = _$rootScope_; + }); + } +}); From db584f7835b995a20fe1f5f68f3344ceb0b99db2 Mon Sep 17 00:00:00 2001 From: Georgios Kalpakas Date: Tue, 28 Jun 2016 00:50:54 +0300 Subject: [PATCH 3/5] feat(ngAria): add support for ignoring a specific element Fixes #14602 Fixes #14672 Closes #14833 --- src/ngAria/aria.js | 24 ++++- test/ngAria/ariaSpec.js | 231 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+), 4 deletions(-) diff --git a/src/ngAria/aria.js b/src/ngAria/aria.js index bf82141b2123..904d9a0e5959 100644 --- a/src/ngAria/aria.js +++ b/src/ngAria/aria.js @@ -14,8 +14,8 @@ * * For ngAria to do its magic, simply include the module `ngAria` as a dependency. The following * directives are supported: - * `ngModel`, `ngChecked`, `ngReadonly`, `ngRequired`, `ngValue`, `ngDisabled`, `ngShow`, `ngHide`, `ngClick`, - * `ngDblClick`, and `ngMessages`. + * `ngModel`, `ngChecked`, `ngReadonly`, `ngRequired`, `ngValue`, `ngDisabled`, `ngShow`, `ngHide`, + * `ngClick`, `ngDblClick`, and `ngMessages`. * * Below is a more detailed breakdown of the attributes handled by ngAria: * @@ -46,11 +46,17 @@ * * ``` * - * ## Disabling Attributes - * It's possible to disable individual attributes added by ngAria with the + * ## Disabling Specific Attributes + * It is possible to disable individual attributes added by ngAria with the * {@link ngAria.$ariaProvider#config config} method. For more details, see the * {@link guide/accessibility Developer Guide}. + * + * ## Disabling `ngAria` on Specific Elements + * It is possible to make `ngAria` ignore a specific element, by adding the `ng-aria-disable` + * attribute on it. Note that only the element itself (and not its child elements) will be ignored. */ +var ARIA_DISABLE_ATTR = 'ngAriaDisable'; + var ngAriaModule = angular.module('ngAria', ['ng']). info({ angularVersion: '"NG_VERSION_FULL"' }). provider('$aria', $AriaProvider); @@ -132,6 +138,8 @@ function $AriaProvider() { function watchExpr(attrName, ariaAttr, nodeBlackList, negate) { return function(scope, elem, attr) { + if (attr.hasOwnProperty(ARIA_DISABLE_ATTR)) return; + var ariaCamelName = attr.$normalize(ariaAttr); if (config[ariaCamelName] && !isNodeOneOf(elem, nodeBlackList) && !attr[ariaCamelName]) { scope.$watch(attr[attrName], function(boolVal) { @@ -251,6 +259,8 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) { require: 'ngModel', priority: 200, //Make sure watches are fired after any other directives that affect the ngModel value compile: function(elem, attr) { + if (attr.hasOwnProperty(ARIA_DISABLE_ATTR)) return; + var shape = getShape(attr, elem); return { @@ -347,6 +357,8 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) { restrict: 'A', require: '?ngMessages', link: function(scope, elem, attr, ngMessages) { + if (attr.hasOwnProperty(ARIA_DISABLE_ATTR)) return; + if (!elem.attr('aria-live')) { elem.attr('aria-live', 'assertive'); } @@ -357,6 +369,8 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) { return { restrict: 'A', compile: function(elem, attr) { + if (attr.hasOwnProperty(ARIA_DISABLE_ATTR)) return; + var fn = $parse(attr.ngClick); return function(scope, elem, attr) { @@ -389,6 +403,8 @@ ngAriaModule.directive('ngShow', ['$aria', function($aria) { }]) .directive('ngDblclick', ['$aria', function($aria) { return function(scope, elem, attr) { + if (attr.hasOwnProperty(ARIA_DISABLE_ATTR)) return; + if ($aria.config('tabindex') && !elem.attr('tabindex') && !isNodeOneOf(elem, nodeBlackList)) { elem.attr('tabindex', 0); } diff --git a/test/ngAria/ariaSpec.js b/test/ngAria/ariaSpec.js index 58cd6584157f..1970b01438b0 100644 --- a/test/ngAria/ariaSpec.js +++ b/test/ngAria/ariaSpec.js @@ -9,6 +9,237 @@ describe('$aria', function() { dealoc(element); }); + describe('with `ngAriaDisable`', function() { + beforeEach(injectScopeAndCompiler); + beforeEach(function() { + jasmine.addMatchers({ + toHaveAttribute: function toHaveAttributeMatcher() { + return { + compare: function toHaveAttributeCompare(element, attr) { + var node = element[0]; + var pass = node.hasAttribute(attr); + var message = 'Expected `' + node.outerHTML + '` ' + (pass ? 'not ' : '') + + 'to have attribute `' + attr + '`.'; + + return { + pass: pass, + message: message + }; + } + }; + } + }); + }); + + // ariaChecked + it('should not attach aria-checked to custom checkbox', function() { + compileElement('
'); + + scope.$apply('val = false'); + expect(element).not.toHaveAttribute('aria-checked'); + + scope.$apply('val = true'); + expect(element).not.toHaveAttribute('aria-checked'); + }); + + it('should not attach aria-checked to custom radio controls', function() { + compileElement( + '
' + + '
'); + + var radio1 = element.eq(0); + var radio2 = element.eq(1); + + scope.$apply('val = "one"'); + expect(radio1).not.toHaveAttribute('aria-checked'); + expect(radio2).not.toHaveAttribute('aria-checked'); + + scope.$apply('val = "two"'); + expect(radio1).not.toHaveAttribute('aria-checked'); + expect(radio2).not.toHaveAttribute('aria-checked'); + }); + + // ariaDisabled + it('should not attach aria-disabled to custom controls', function() { + compileElement('
'); + + scope.$apply('val = false'); + expect(element).not.toHaveAttribute('aria-disabled'); + + scope.$apply('val = true'); + expect(element).not.toHaveAttribute('aria-disabled'); + }); + + // ariaHidden + it('should not attach aria-hidden to `ngShow`', function() { + compileElement('
'); + + scope.$apply('val = false'); + expect(element).not.toHaveAttribute('aria-hidden'); + + scope.$apply('val = true'); + expect(element).not.toHaveAttribute('aria-hidden'); + }); + + it('should not attach aria-hidden to `ngHide`', function() { + compileElement('
'); + + scope.$apply('val = false'); + expect(element).not.toHaveAttribute('aria-hidden'); + + scope.$apply('val = true'); + expect(element).not.toHaveAttribute('aria-hidden'); + }); + + // ariaInvalid + it('should not attach aria-invalid to input', function() { + compileElement(''); + + scope.$apply('val = "lt 10"'); + expect(element).not.toHaveAttribute('aria-invalid'); + + scope.$apply('val = "gt 10 characters"'); + expect(element).not.toHaveAttribute('aria-invalid'); + }); + + it('should not attach aria-invalid to custom controls', function() { + compileElement('
'); + + scope.$apply('val = "lt 10"'); + expect(element).not.toHaveAttribute('aria-invalid'); + + scope.$apply('val = "gt 10 characters"'); + expect(element).not.toHaveAttribute('aria-invalid'); + }); + + // ariaLive + it('should not attach aria-live to `ngMessages`', function() { + compileElement('
'); + expect(element).not.toHaveAttribute('aria-live'); + }); + + // ariaReadonly + it('should not attach aria-readonly to custom controls', function() { + compileElement('
'); + + scope.$apply('val = false'); + expect(element).not.toHaveAttribute('aria-readonly'); + + scope.$apply('val = true'); + expect(element).not.toHaveAttribute('aria-readonly'); + }); + + // ariaRequired + it('should not attach aria-required to custom controls with `required`', function() { + compileElement('
'); + expect(element).not.toHaveAttribute('aria-required'); + }); + + it('should not attach aria-required to custom controls with `ngRequired`', function() { + compileElement('
'); + + scope.$apply('val = false'); + expect(element).not.toHaveAttribute('aria-required'); + + scope.$apply('val = true'); + expect(element).not.toHaveAttribute('aria-required'); + }); + + // ariaValue + it('should not attach aria-value* to input[range]', function() { + compileElement(''); + + expect(element).not.toHaveAttribute('aria-valuemax'); + expect(element).not.toHaveAttribute('aria-valuemin'); + expect(element).not.toHaveAttribute('aria-valuenow'); + + scope.$apply('val = 50'); + expect(element).not.toHaveAttribute('aria-valuemax'); + expect(element).not.toHaveAttribute('aria-valuemin'); + expect(element).not.toHaveAttribute('aria-valuenow'); + + scope.$apply('val = 150'); + expect(element).not.toHaveAttribute('aria-valuemax'); + expect(element).not.toHaveAttribute('aria-valuemin'); + expect(element).not.toHaveAttribute('aria-valuenow'); + }); + + it('should not attach aria-value* to custom controls', function() { + compileElement( + '
' + + '
'); + + var progressbar = element.eq(0); + var slider = element.eq(1); + + ['aria-valuemax', 'aria-valuemin', 'aria-valuenow'].forEach(function(attr) { + expect(progressbar).not.toHaveAttribute(attr); + expect(slider).not.toHaveAttribute(attr); + }); + + scope.$apply('val = 50'); + ['aria-valuemax', 'aria-valuemin', 'aria-valuenow'].forEach(function(attr) { + expect(progressbar).not.toHaveAttribute(attr); + expect(slider).not.toHaveAttribute(attr); + }); + + scope.$apply('val = 150'); + ['aria-valuemax', 'aria-valuemin', 'aria-valuenow'].forEach(function(attr) { + expect(progressbar).not.toHaveAttribute(attr); + expect(slider).not.toHaveAttribute(attr); + }); + }); + + // bindKeypress + it('should not bind keypress to `ngClick`', function() { + scope.onClick = jasmine.createSpy('onClick'); + compileElement( + '
' + + '
'); + + var div = element.find('div'); + var li = element.find('li'); + + div.triggerHandler({type: 'keypress', keyCode: 32}); + li.triggerHandler({type: 'keypress', keyCode: 32}); + + expect(scope.onClick).not.toHaveBeenCalled(); + }); + + // bindRoleForClick + it('should not attach role to custom controls', function() { + compileElement( + '
' + + '
' + + '
' + + '
'); + + expect(element.eq(0)).not.toHaveAttribute('role'); + expect(element.eq(1)).not.toHaveAttribute('role'); + expect(element.eq(2)).not.toHaveAttribute('role'); + expect(element.eq(3)).not.toHaveAttribute('role'); + }); + + // tabindex + it('should not attach tabindex to custom controls', function() { + compileElement( + '
' + + '
'); + + expect(element.eq(0)).not.toHaveAttribute('tabindex'); + expect(element.eq(1)).not.toHaveAttribute('tabindex'); + }); + + it('should not attach tabindex to `ngClick` or `ngDblclick`', function() { + compileElement( + '
' + + '
'); + + expect(element.eq(0)).not.toHaveAttribute('tabindex'); + expect(element.eq(1)).not.toHaveAttribute('tabindex'); + }); + }); + describe('aria-hidden', function() { beforeEach(injectScopeAndCompiler); From 05170bf3716359f81b32bf5113330dd6240a0cf7 Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Fri, 8 Jun 2018 16:14:14 +0300 Subject: [PATCH 4/5] docs(CHANGELOG): add release notes for 1.7.1 --- CHANGELOG.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f1531a96b2..a20798781423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,59 @@ + +# 1.7.1 momentum-defiance (2018-06-08) + + +## Bug Fixes +- **$compile:** support transcluding multi-element directives + ([789db8](https://github.com/angular/angular.js/commit/789db83a8ae0e2db5db13289b2c29e56093d967a), + [#15554](https://github.com/angular/angular.js.git/issues/15554), + [#15555](https://github.com/angular/angular.js.git/issues/15555)) +- **ngModel:** do not throw if view value changes on destroyed scope + ([2b6c98](https://github.com/angular/angular.js/commit/2b6c9867369fd3ef1ddb687af1153478ab62ee1b), + [#16583](https://github.com/angular/angular.js.git/issues/16583), + [#16585](https://github.com/angular/angular.js.git/issues/16585)) + + +## New Features +- **$compile:** add one-way collection bindings + ([f9d1ca](https://github.com/angular/angular.js/commit/f9d1ca20c38f065f15769fbe23aee5314cb58bd4), + [#14039](https://github.com/angular/angular.js.git/issues/14039), + [#16553](https://github.com/angular/angular.js.git/issues/16553), + [#15874](https://github.com/angular/angular.js.git/issues/15874)) +- **ngRef:** add directive to publish controller, or element into scope + ([bf841d](https://github.com/angular/angular.js/commit/bf841d35120bf3c4655fde46af4105c85a0f1cdc), + [#16511](https://github.com/angular/angular.js.git/issues/16511)) +- **errorHandlingConfig:** add option to exclude error params from url + ([3d6c45](https://github.com/angular/angular.js/commit/3d6c45d76e30b1b3c4eb9672cf4a93e5251c06b3), + [#14744](https://github.com/angular/angular.js.git/issues/14744), + [#15707](https://github.com/angular/angular.js.git/issues/15707), + [#16283](https://github.com/angular/angular.js.git/issues/16283), + [#16299](https://github.com/angular/angular.js.git/issues/16299), + [#16591](https://github.com/angular/angular.js.git/issues/16591)) +- **ngAria:** add support for ignoring a specific element + ([7d9d38](https://github.com/angular/angular.js/commit/7d9d387195292cb5e04984602b752d31853cfea6), + [#14602](https://github.com/angular/angular.js.git/issues/14602), + [#14672](https://github.com/angular/angular.js.git/issues/14672), + [#14833](https://github.com/angular/angular.js.git/issues/14833)) +- **ngCookies:** support samesite option + ([10a229](https://github.com/angular/angular.js/commit/10a229ce1befdeaf6295d1635dc11391c252a91a), + [#16543](https://github.com/angular/angular.js.git/issues/16543), + [#16544](https://github.com/angular/angular.js.git/issues/16544)) +- **ngMessages:** add support for default message + ([a8c263](https://github.com/angular/angular.js/commit/a8c263c1947cc85ee60b4732f7e4bcdc7ba463e8), + [#12008](https://github.com/angular/angular.js.git/issues/12008), + [#12213](https://github.com/angular/angular.js.git/issues/12213), + [#16587](https://github.com/angular/angular.js.git/issues/16587)) +- **ngMock, ngMockE2E:** add option to match latest definition for `$httpBackend` request + ([773f39](https://github.com/angular/angular.js/commit/773f39c9345479f5f8b6321236ce6ad96f77aa92), + [#16251](https://github.com/angular/angular.js.git/issues/16251), + [#11637](https://github.com/angular/angular.js.git/issues/11637), + [#16560](https://github.com/angular/angular.js.git/issues/16560)) +- **$route:** add support for the `reloadOnUrl` configuration option + ([f4f571](https://github.com/angular/angular.js/commit/f4f571efdf86d6acbcd5c6b1de66b4b33a259125), + [#7925](https://github.com/angular/angular.js.git/issues/7925), + [#15002](https://github.com/angular/angular.js.git/issues/15002)) + + # 1.7.0 nonexistent-physiology (2018-05-11) From ad0ba99d8a231da9796eb94adcfa670665ea0eed Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Fri, 8 Jun 2018 17:09:30 +0300 Subject: [PATCH 5/5] docs(CHANGELOG): fix links to issues/PRs --- CHANGELOG.md | 52 ++++++++++++++++++++++++++-------------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a20798781423..29176742999d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,53 +5,53 @@ ## Bug Fixes - **$compile:** support transcluding multi-element directives ([789db8](https://github.com/angular/angular.js/commit/789db83a8ae0e2db5db13289b2c29e56093d967a), - [#15554](https://github.com/angular/angular.js.git/issues/15554), - [#15555](https://github.com/angular/angular.js.git/issues/15555)) + [#15554](https://github.com/angular/angular.js/issues/15554), + [#15555](https://github.com/angular/angular.js/issues/15555)) - **ngModel:** do not throw if view value changes on destroyed scope ([2b6c98](https://github.com/angular/angular.js/commit/2b6c9867369fd3ef1ddb687af1153478ab62ee1b), - [#16583](https://github.com/angular/angular.js.git/issues/16583), - [#16585](https://github.com/angular/angular.js.git/issues/16585)) + [#16583](https://github.com/angular/angular.js/issues/16583), + [#16585](https://github.com/angular/angular.js/issues/16585)) ## New Features - **$compile:** add one-way collection bindings ([f9d1ca](https://github.com/angular/angular.js/commit/f9d1ca20c38f065f15769fbe23aee5314cb58bd4), - [#14039](https://github.com/angular/angular.js.git/issues/14039), - [#16553](https://github.com/angular/angular.js.git/issues/16553), - [#15874](https://github.com/angular/angular.js.git/issues/15874)) + [#14039](https://github.com/angular/angular.js/issues/14039), + [#16553](https://github.com/angular/angular.js/issues/16553), + [#15874](https://github.com/angular/angular.js/issues/15874)) - **ngRef:** add directive to publish controller, or element into scope ([bf841d](https://github.com/angular/angular.js/commit/bf841d35120bf3c4655fde46af4105c85a0f1cdc), - [#16511](https://github.com/angular/angular.js.git/issues/16511)) + [#16511](https://github.com/angular/angular.js/issues/16511)) - **errorHandlingConfig:** add option to exclude error params from url ([3d6c45](https://github.com/angular/angular.js/commit/3d6c45d76e30b1b3c4eb9672cf4a93e5251c06b3), - [#14744](https://github.com/angular/angular.js.git/issues/14744), - [#15707](https://github.com/angular/angular.js.git/issues/15707), - [#16283](https://github.com/angular/angular.js.git/issues/16283), - [#16299](https://github.com/angular/angular.js.git/issues/16299), - [#16591](https://github.com/angular/angular.js.git/issues/16591)) + [#14744](https://github.com/angular/angular.js/issues/14744), + [#15707](https://github.com/angular/angular.js/issues/15707), + [#16283](https://github.com/angular/angular.js/issues/16283), + [#16299](https://github.com/angular/angular.js/issues/16299), + [#16591](https://github.com/angular/angular.js/issues/16591)) - **ngAria:** add support for ignoring a specific element ([7d9d38](https://github.com/angular/angular.js/commit/7d9d387195292cb5e04984602b752d31853cfea6), - [#14602](https://github.com/angular/angular.js.git/issues/14602), - [#14672](https://github.com/angular/angular.js.git/issues/14672), - [#14833](https://github.com/angular/angular.js.git/issues/14833)) + [#14602](https://github.com/angular/angular.js/issues/14602), + [#14672](https://github.com/angular/angular.js/issues/14672), + [#14833](https://github.com/angular/angular.js/issues/14833)) - **ngCookies:** support samesite option ([10a229](https://github.com/angular/angular.js/commit/10a229ce1befdeaf6295d1635dc11391c252a91a), - [#16543](https://github.com/angular/angular.js.git/issues/16543), - [#16544](https://github.com/angular/angular.js.git/issues/16544)) + [#16543](https://github.com/angular/angular.js/issues/16543), + [#16544](https://github.com/angular/angular.js/issues/16544)) - **ngMessages:** add support for default message ([a8c263](https://github.com/angular/angular.js/commit/a8c263c1947cc85ee60b4732f7e4bcdc7ba463e8), - [#12008](https://github.com/angular/angular.js.git/issues/12008), - [#12213](https://github.com/angular/angular.js.git/issues/12213), - [#16587](https://github.com/angular/angular.js.git/issues/16587)) + [#12008](https://github.com/angular/angular.js/issues/12008), + [#12213](https://github.com/angular/angular.js/issues/12213), + [#16587](https://github.com/angular/angular.js/issues/16587)) - **ngMock, ngMockE2E:** add option to match latest definition for `$httpBackend` request ([773f39](https://github.com/angular/angular.js/commit/773f39c9345479f5f8b6321236ce6ad96f77aa92), - [#16251](https://github.com/angular/angular.js.git/issues/16251), - [#11637](https://github.com/angular/angular.js.git/issues/11637), - [#16560](https://github.com/angular/angular.js.git/issues/16560)) + [#16251](https://github.com/angular/angular.js/issues/16251), + [#11637](https://github.com/angular/angular.js/issues/11637), + [#16560](https://github.com/angular/angular.js/issues/16560)) - **$route:** add support for the `reloadOnUrl` configuration option ([f4f571](https://github.com/angular/angular.js/commit/f4f571efdf86d6acbcd5c6b1de66b4b33a259125), - [#7925](https://github.com/angular/angular.js.git/issues/7925), - [#15002](https://github.com/angular/angular.js.git/issues/15002)) + [#7925](https://github.com/angular/angular.js/issues/7925), + [#15002](https://github.com/angular/angular.js/issues/15002))