diff --git a/FEATURES.md b/FEATURES.md index 88b3c82dafc..093ed56a93a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -111,3 +111,14 @@ for a detailed explanation. to perform the lookup instead. Replaces the need for `needs` in controllers. Added in [#5162](https://github.com/emberjs/ember.js/pull/5162). + +* `ember-routing-transitioning-classes` + + Disables eager URL updates during slow transitions in favor of new CSS + classes added to `link-to`s (in addition to `active` class): + + - `transitioning-in`: link-to is not currently active, but will be + when the current underway (slow) transition completes. + - `transitioning-out`: link-to is currently active, but will no longer + be active when the current underway (slow) transition completes. + diff --git a/bower.json b/bower.json index 2137fb47d40..aa87f11710b 100644 --- a/bower.json +++ b/bower.json @@ -9,7 +9,7 @@ "devDependencies": { "backburner": "https://github.com/ebryn/backburner.js.git#f4bd6a2df221240ed36d140f0c53c036a7ecacad", "rsvp": "https://github.com/tildeio/rsvp.js.git#3.0.14", - "router.js": "https://github.com/tildeio/router.js.git#9471aaaa28c11907d8fdf8969b70c6a93ad19fd1", + "router.js": "https://github.com/tildeio/router.js.git#a1ffd97dc66a6d9d4e8dd89a72c1c4e21a3328c5", "route-recognizer": "https://github.com/tildeio/route-recognizer.git#8e1058e29de741b8e05690c69da9ec402a167c69", "dag-map": "https://github.com/krisselden/dag-map.git#e307363256fe918f426e5a646cb5f5062d3245be", "ember-dev": "https://github.com/emberjs/ember-dev.git#1c30a1666273ab2a9b134a42bad28c774f9ecdfc" diff --git a/features.json b/features.json index c125ff338e0..c2301a639d8 100644 --- a/features.json +++ b/features.json @@ -12,7 +12,8 @@ "ember-htmlbars-block-params": true, "ember-htmlbars-component-generation": null, "ember-htmlbars-inline-if-helper": null, - "ember-htmlbars-attribute-syntax": null + "ember-htmlbars-attribute-syntax": null, + "ember-routing-transitioning-classes": null }, "debugStatements": [ "Ember.warn", diff --git a/packages/ember-routing-views/lib/views/link.js b/packages/ember-routing-views/lib/views/link.js index 102dc48db3f..d3242540cdb 100644 --- a/packages/ember-routing-views/lib/views/link.js +++ b/packages/ember-routing-views/lib/views/link.js @@ -27,6 +27,11 @@ var numberOfContextsAcceptedByHandler = function(handler, handlerInfos) { return req; }; +var linkViewClassNameBindings = ['active', 'loading', 'disabled']; +if (Ember.FEATURES.isEnabled('ember-routing-transitioning-classes')) { + linkViewClassNameBindings = ['active', 'loading', 'disabled', 'transitioningIn', 'transitioningOut']; +} + /** `Ember.LinkView` renders an element whose `click` event triggers a transition of the application's instance of `Ember.Router` to @@ -149,7 +154,7 @@ var LinkView = Ember.LinkView = EmberComponent.extend({ @type Array @default ['active', 'loading', 'disabled'] **/ - classNameBindings: ['active', 'loading', 'disabled'], + classNameBindings: linkViewClassNameBindings, /** By default the `{{link-to}}` helper responds to the `click` event. You @@ -289,56 +294,32 @@ var LinkView = Ember.LinkView = EmberComponent.extend({ @property active **/ active: computed('loadedParams', function computeLinkViewActive() { - if (get(this, 'loading')) { return false; } + var router = get(this, 'router'); + if (!router) { return; } + return computeActive(this, router.currentState); + }), + willBeActive: computed('router.targetState', function() { var router = get(this, 'router'); - var loadedParams = get(this, 'loadedParams'); - var contexts = loadedParams.models; - var currentWhen = this['current-when'] || this.currentWhen; - var isCurrentWhenSpecified = Boolean(currentWhen); - currentWhen = currentWhen || loadedParams.targetRouteName; - - function isActiveForRoute(routeName) { - var handlers = router.router.recognizer.handlersFor(routeName); - var leafName = handlers[handlers.length-1].handler; - var maximumContexts = numberOfContextsAcceptedByHandler(routeName, handlers); - - // NOTE: any ugliness in the calculation of activeness is largely - // due to the fact that we support automatic normalizing of - // `resource` -> `resource.index`, even though there might be - // dynamic segments / query params defined on `resource.index` - // which complicates (and makes somewhat ambiguous) the calculation - // of activeness for links that link to `resource` instead of - // directly to `resource.index`. - - // if we don't have enough contexts revert back to full route name - // this is because the leaf route will use one of the contexts - if (contexts.length > maximumContexts) { - routeName = leafName; - } + if (!router) { return; } + var targetState = router.targetState; + if (router.currentState === targetState) { return; } - var args = routeArgs(routeName, contexts, null); - var isActive = router.isActive.apply(router, args); - if (!isActive) { return false; } + return !!computeActive(this, targetState); + }), - var emptyQueryParams = Ember.isEmpty(Ember.keys(loadedParams.queryParams)); + transitioningIn: computed('active', 'willBeActive', function() { + var willBeActive = get(this, 'willBeActive'); + if (typeof willBeActive === 'undefined') { return false; } - if (!isCurrentWhenSpecified && !emptyQueryParams && isActive) { - var visibleQueryParams = {}; - merge(visibleQueryParams, loadedParams.queryParams); - router._prepareQueryParams(loadedParams.targetRouteName, loadedParams.models, visibleQueryParams); - isActive = shallowEqual(visibleQueryParams, router.router.state.queryParams); - } + return !get(this, 'active') && willBeActive; + }), - return isActive; - } + transitioningOut: computed('active', 'willBeActive', function() { + var willBeActive = get(this, 'willBeActive'); + if (typeof willBeActive === 'undefined') { return false; } - currentWhen = currentWhen.split(' '); - for (var i = 0, len = currentWhen.length; i < len; i++) { - if (isActiveForRoute(currentWhen[i])) { - return get(this, 'activeClass'); - } - } + return get(this, 'active') && !willBeActive; }), /** @@ -408,6 +389,10 @@ var LinkView = Ember.LinkView = EmberComponent.extend({ transition.method('replace'); } + if (Ember.FEATURES.isEnabled('ember-routing-transitioning-classes')) { + return; + } + // Schedule eager URL update, but after we've given the transition // a chance to synchronously redirect. // We need to always generate the URL instead of using the href because @@ -591,15 +576,44 @@ function paramsAreLoaded(params) { return true; } -function shallowEqual(a, b) { - var k; - for (k in a) { - if (a.hasOwnProperty(k) && a[k] !== b[k]) { return false; } +function computeActive(route, routerState) { + if (get(route, 'loading')) { return false; } + + var currentWhen = route['current-when'] || route.currentWhen; + var isCurrentWhenSpecified = !!currentWhen; + currentWhen = currentWhen || get(route, 'loadedParams').targetRouteName; + currentWhen = currentWhen.split(' '); + for (var i = 0, len = currentWhen.length; i < len; i++) { + if (isActiveForRoute(route, currentWhen[i], isCurrentWhenSpecified, routerState)) { + return get(route, 'activeClass'); + } } - for (k in b) { - if (b.hasOwnProperty(k) && a[k] !== b[k]) { return false; } +} + +function isActiveForRoute(route, routeName, isCurrentWhenSpecified, routerState) { + var router = get(route, 'router'); + var loadedParams = get(route, 'loadedParams'); + var contexts = loadedParams.models; + + var handlers = router.router.recognizer.handlersFor(routeName); + var leafName = handlers[handlers.length-1].handler; + var maximumContexts = numberOfContextsAcceptedByHandler(routeName, handlers); + + // NOTE: any ugliness in the calculation of activeness is largely + // due to the fact that we support automatic normalizing of + // `resource` -> `resource.index`, even though there might be + // dynamic segments / query params defined on `resource.index` + // which complicates (and makes somewhat ambiguous) the calculation + // of activeness for links that link to `resource` instead of + // directly to `resource.index`. + + // if we don't have enough contexts revert back to full route name + // this is because the leaf route will use one of the contexts + if (contexts.length > maximumContexts) { + routeName = leafName; } - return true; + + return routerState.isActiveIntent(routeName, contexts, loadedParams.queryParams, !isCurrentWhenSpecified); } export { diff --git a/packages/ember-routing/lib/system/router.js b/packages/ember-routing/lib/system/router.js index 8cef0981305..bf9ce158e3d 100644 --- a/packages/ember-routing/lib/system/router.js +++ b/packages/ember-routing/lib/system/router.js @@ -21,6 +21,8 @@ import { } from "ember-routing/utils"; import { create } from "ember-metal/platform"; +import RouterState from "./router_state"; + /** @module ember @submodule ember-routing @@ -147,9 +149,10 @@ var EmberRouter = EmberObject.extend(Evented, { didTransition: function(infos) { updatePaths(this); - this._cancelLoadingEvent(); + this._cancelSlowTransitionTimer(); this.notifyPropertyChange('url'); + this.set('currentState', this.targetState); // Put this in the runloop so url will be accurate. Seems // less surprising than didTransition being out of sync. @@ -169,7 +172,7 @@ var EmberRouter = EmberObject.extend(Evented, { _doURLTransition: function(routerJsMethod, url) { var transition = this.router[routerJsMethod](url || '/'); - listenForTransitionErrors(transition); + didBeginTransition(transition, this); return transition; }, @@ -237,8 +240,7 @@ var EmberRouter = EmberObject.extend(Evented, { @since 1.7.0 */ isActiveIntent: function(routeName, models, queryParams) { - var router = this.router; - return router.isActive.apply(router, arguments); + return this.currentState.isActiveIntent(routeName, models, queryParams); }, send: function(name, context) { @@ -441,7 +443,7 @@ var EmberRouter = EmberObject.extend(Evented, { var transitionArgs = routeArgs(targetRouteName, models, queryParams); var transitionPromise = this.router.transitionTo.apply(this.router, transitionArgs); - listenForTransitionErrors(transitionPromise); + didBeginTransition(transitionPromise, this); return transitionPromise; }, @@ -539,25 +541,34 @@ var EmberRouter = EmberObject.extend(Evented, { }, _scheduleLoadingEvent: function(transition, originRoute) { - this._cancelLoadingEvent(); - this._loadingStateTimer = run.scheduleOnce('routerTransitions', this, '_fireLoadingEvent', transition, originRoute); + this._cancelSlowTransitionTimer(); + this._slowTransitionTimer = run.scheduleOnce('routerTransitions', this, '_handleSlowTransition', transition, originRoute); }, - _fireLoadingEvent: function(transition, originRoute) { + currentState: null, + targetState: null, + + _handleSlowTransition: function(transition, originRoute) { if (!this.router.activeTransition) { // Don't fire an event if we've since moved on from // the transition that put us in a loading state. return; } + this.set('targetState', RouterState.create({ + emberRouter: this, + routerJs: this.router, + routerJsState: this.router.activeTransition.state + })); + transition.trigger(true, 'loading', transition, originRoute); }, - _cancelLoadingEvent: function () { - if (this._loadingStateTimer) { - run.cancel(this._loadingStateTimer); + _cancelSlowTransitionTimer: function () { + if (this._slowTransitionTimer) { + run.cancel(this._slowTransitionTimer); } - this._loadingStateTimer = null; + this._slowTransitionTimer = null; } }); @@ -859,7 +870,18 @@ EmberRouter.reopenClass({ } }); -function listenForTransitionErrors(transition) { +function didBeginTransition(transition, router) { + var routerState = RouterState.create({ + emberRouter: router, + routerJs: router.router, + routerJsState: transition.state + }); + + if (!router.currentState) { + router.set('currentState', routerState); + } + router.set('targetState', routerState); + transition.then(null, function(error) { if (!error || !error.name) { return; } diff --git a/packages/ember-routing/lib/system/router_state.js b/packages/ember-routing/lib/system/router_state.js new file mode 100644 index 00000000000..c12cb093acd --- /dev/null +++ b/packages/ember-routing/lib/system/router_state.js @@ -0,0 +1,40 @@ +import Ember from "ember-metal/core"; +import EmberObject from "ember-runtime/system/object"; +import merge from "ember-metal/merge"; + +var RouterState = EmberObject.extend({ + emberRouter: null, + routerJs: null, + routerJsState: null, + + isActiveIntent: function(routeName, models, queryParams, queryParamsMustMatch) { + var state = this.routerJsState; + if (!this.routerJs.isActiveIntent(routeName, models, null, state)) { return false; } + + var emptyQueryParams = Ember.isEmpty(Ember.keys(queryParams)); + + if (queryParamsMustMatch && !emptyQueryParams) { + var visibleQueryParams = {}; + merge(visibleQueryParams, queryParams); + + this.emberRouter._prepareQueryParams(routeName, models, visibleQueryParams); + return shallowEqual(visibleQueryParams, state.queryParams); + } + + return true; + } +}); + +function shallowEqual(a, b) { + var k; + for (k in a) { + if (a.hasOwnProperty(k) && a[k] !== b[k]) { return false; } + } + for (k in b) { + if (b.hasOwnProperty(k) && a[k] !== b[k]) { return false; } + } + return true; +} + +export default RouterState; + diff --git a/packages/ember/tests/helpers/link_to_test.js b/packages/ember/tests/helpers/link_to_test.js index 25c1f90833a..76aa6effdad 100644 --- a/packages/ember/tests/helpers/link_to_test.js +++ b/packages/ember/tests/helpers/link_to_test.js @@ -622,7 +622,12 @@ test("Issue 4201 - Shorthand for route.index shouldn't throw errors about contex }); test("The {{link-to}} helper unwraps controllers", function() { - expect(5); + + if (Ember.FEATURES.isEnabled('ember-routing-transitioning-classes')) { + expect(4); + } else { + expect(5); + } Router.map(function() { this.route('filter', { path: '/filters/:filter' }); @@ -1637,6 +1642,8 @@ function basicEagerURLUpdateTest(setTagName) { } var aboutDefer; + +if (!Ember.FEATURES.isEnabled('ember-routing-transitioning-classes')) { QUnit.module("The {{link-to}} helper: eager URL updating", { setup: function() { Ember.run(function() { @@ -1753,3 +1760,66 @@ test("invoking a link-to whose transition gets aborted in will transition doesn' equal(router.get('location.path'), '', 'url was not updated'); }); +} + +if (Ember.FEATURES.isEnabled('ember-routing-transitioning-classes')) { + + QUnit.module("The {{link-to}} helper: .transitioning-in .transitioning-out CSS classes", { + setup: function() { + Ember.run(function() { + sharedSetup(); + + container.register('router:main', Router); + + Router.map(function() { + this.route('about'); + this.route('other'); + }); + + App.AboutRoute = Ember.Route.extend({ + model: function() { + aboutDefer = Ember.RSVP.defer(); + return aboutDefer.promise; + } + }); + + Ember.TEMPLATES.application = compile("{{outlet}}{{link-to 'Index' 'index' id='index-link'}}{{link-to 'About' 'about' id='about-link'}}{{link-to 'Other' 'other' id='other-link'}}"); + }); + }, + + teardown: function() { + sharedTeardown(); + aboutDefer = null; + } + }); + + test("while a transition is underway", function() { + expect(18); + bootApplication(); + + function assertHasClass(className) { + var i = 1; + while (i < arguments.length) { + var $a = arguments[i]; + var shouldHaveClass = arguments[i+1]; + equal($a.hasClass(className), shouldHaveClass, $a.attr('id') + " should " + (shouldHaveClass ? '' : "not ") + "have class " + className); + i +=2; + } + } + + var $index = Ember.$('#index-link'), $about = Ember.$('#about-link'), $other = Ember.$('#other-link'); + + Ember.run($about, 'click'); + + assertHasClass('active', $index, true, $about, false, $other, false); + assertHasClass('transitioning-in', $index, false, $about, true, $other, false); + assertHasClass('transitioning-out', $index, true, $about, false, $other, false); + + Ember.run(aboutDefer, 'resolve'); + + assertHasClass('active', $index, false, $about, true, $other, false); + assertHasClass('transitioning-in', $index, false, $about, false, $other, false); + assertHasClass('transitioning-out', $index, false, $about, false, $other, false); + }); +} +