Skip to content

Commit 3831af1

Browse files
committed
feat(uiState): add ui-state directive
- Refactor StateRefDirective for better modularity - Drop key restrictions on ui-sref-opts - Improves performance over prior implementation with no extra $eval()’s Fixes #395, #900, #1932
1 parent 17e7757 commit 3831af1

File tree

3 files changed

+179
-99
lines changed

3 files changed

+179
-99
lines changed

src/stateDirectives.js

+113-71
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,43 @@ function stateContext(el) {
1414
}
1515
}
1616

17+
function getTypeInfo(el) {
18+
// SVGAElement does not use the href attribute, but rather the 'xlinkHref' attribute.
19+
var isSvg = Object.prototype.toString.call(el.prop('href')) === '[object SVGAnimatedString]';
20+
var isForm = el[0].nodeName === "FORM";
21+
22+
return {
23+
attr: isForm ? "action" : (isSvg ? 'xlink:href' : 'href'),
24+
isAnchor: el.prop("tagName").toUpperCase() === "A",
25+
clickable: !isForm
26+
};
27+
}
28+
29+
function clickHook(el, $state, $timeout, type, current) {
30+
return function(e) {
31+
var button = e.which || e.button, target = current();
32+
33+
if (!(button > 1 || e.ctrlKey || e.metaKey || e.shiftKey || el.attr('target'))) {
34+
// HACK: This is to allow ng-clicks to be processed before the transition is initiated:
35+
var transition = $timeout(function() {
36+
$state.go(target.state, target.params, target.options);
37+
});
38+
e.preventDefault();
39+
40+
// if the state has no URL, ignore one preventDefault from the <a> directive.
41+
var ignorePreventDefaultCount = type.isAnchor && !target.href ? 1: 0;
42+
43+
e.preventDefault = function() {
44+
if (ignorePreventDefaultCount-- <= 0) $timeout.cancel(transition);
45+
};
46+
}
47+
};
48+
}
49+
50+
function defaultOpts(el, $state) {
51+
return { relative: stateContext(el) || $state.$current, inherit: true };
52+
}
53+
1754
/**
1855
* @ngdoc directive
1956
* @name ui.router.state.directive:ui-sref
@@ -24,40 +61,40 @@ function stateContext(el) {
2461
* @restrict A
2562
*
2663
* @description
27-
* A directive that binds a link (`<a>` tag) to a state. If the state has an associated
28-
* URL, the directive will automatically generate & update the `href` attribute via
29-
* the {@link ui.router.state.$state#methods_href $state.href()} method. Clicking
30-
* the link will trigger a state transition with optional parameters.
64+
* A directive that binds a link (`<a>` tag) to a state. If the state has an associated
65+
* URL, the directive will automatically generate & update the `href` attribute via
66+
* the {@link ui.router.state.$state#methods_href $state.href()} method. Clicking
67+
* the link will trigger a state transition with optional parameters.
3168
*
32-
* Also middle-clicking, right-clicking, and ctrl-clicking on the link will be
69+
* Also middle-clicking, right-clicking, and ctrl-clicking on the link will be
3370
* handled natively by the browser.
3471
*
35-
* You can also use relative state paths within ui-sref, just like the relative
72+
* You can also use relative state paths within ui-sref, just like the relative
3673
* paths passed to `$state.go()`. You just need to be aware that the path is relative
37-
* to the state that the link lives in, in other words the state that loaded the
74+
* to the state that the link lives in, in other words the state that loaded the
3875
* template containing the link.
3976
*
4077
* You can specify options to pass to {@link ui.router.state.$state#go $state.go()}
4178
* using the `ui-sref-opts` attribute. Options are restricted to `location`, `inherit`,
4279
* and `reload`.
4380
*
4481
* @example
45-
* Here's an example of how you'd use ui-sref and how it would compile. If you have the
82+
* Here's an example of how you'd use ui-sref and how it would compile. If you have the
4683
* following template:
4784
* <pre>
4885
* <a ui-sref="home">Home</a> | <a ui-sref="about">About</a> | <a ui-sref="{page: 2}">Next page</a>
49-
*
86+
*
5087
* <ul>
5188
* <li ng-repeat="contact in contacts">
5289
* <a ui-sref="contacts.detail({ id: contact.id })">{{ contact.name }}</a>
5390
* </li>
5491
* </ul>
5592
* </pre>
56-
*
93+
*
5794
* Then the compiled html would be (assuming Html5Mode is off and current state is contacts):
5895
* <pre>
5996
* <a href="#/home" ui-sref="home">Home</a> | <a href="#/about" ui-sref="about">About</a> | <a href="#/contacts?page=2" ui-sref="{page: 2}">Next page</a>
60-
*
97+
*
6198
* <ul>
6299
* <li ng-repeat="contact in contacts">
63100
* <a href="#/contacts/1" ui-sref="contacts.detail({ id: contact.id })">Joe</a>
@@ -78,78 +115,86 @@ function stateContext(el) {
78115
*/
79116
$StateRefDirective.$inject = ['$state', '$timeout'];
80117
function $StateRefDirective($state, $timeout) {
81-
var allowedOptions = ['location', 'inherit', 'reload', 'absolute'];
82-
83118
return {
84119
restrict: 'A',
85120
require: ['?^uiSrefActive', '?^uiSrefActiveEq'],
86121
link: function(scope, element, attrs, uiSrefActive) {
87-
var ref = parseStateRef(attrs.uiSref, $state.current.name);
88-
var params = null, url = null, base = stateContext(element) || $state.$current;
89-
// SVGAElement does not use the href attribute, but rather the 'xlinkHref' attribute.
90-
var hrefKind = Object.prototype.toString.call(element.prop('href')) === '[object SVGAnimatedString]' ?
91-
'xlink:href' : 'href';
92-
var newHref = null, isAnchor = element.prop("tagName").toUpperCase() === "A";
93-
var isForm = element[0].nodeName === "FORM";
94-
var attr = isForm ? "action" : hrefKind, nav = true;
95-
96-
var options = { relative: base, inherit: true };
97-
var optionsOverride = scope.$eval(attrs.uiSrefOpts) || {};
98-
99-
angular.forEach(allowedOptions, function(option) {
100-
if (option in optionsOverride) {
101-
options[option] = optionsOverride[option];
102-
}
103-
});
122+
var ref = parseStateRef(attrs.uiSref, $state.current.name);
123+
var def = { state: ref.state, href: null, nav: true, params: null };
124+
var type = getTypeInfo(element);
125+
var active = uiSrefActive[1] || uiSrefActive[0];
104126

105-
var update = function(newVal) {
106-
if (newVal) params = angular.copy(newVal);
107-
if (!nav) return;
127+
def.options = extend(defaultOpts(element, $state), attrs.uiSrefOpts ? scope.$eval(attrs.uiSrefOpts) : {});
108128

109-
newHref = $state.href(ref.state, params, options);
129+
var update = function(val) {
130+
if (val) def.params = angular.copy(val);
131+
if (!def.nav) return;
110132

111-
var activeDirective = uiSrefActive[1] || uiSrefActive[0];
112-
if (activeDirective) {
113-
activeDirective.$$addStateInfo(ref.state, params);
114-
}
115-
if (newHref === null) {
116-
nav = false;
117-
return false;
118-
}
119-
attrs.$set(attr, newHref);
133+
def.href = $state.href(ref.state, def.params, def.options);
134+
def.nav = (def.href !== null);
135+
136+
if (active) active.$$addStateInfo(ref.state, def.params);
137+
if (def.nav) attrs.$set(type.attr, def.href);
120138
};
121139

122140
if (ref.paramExpr) {
123-
scope.$watch(ref.paramExpr, function(newVal, oldVal) {
124-
if (newVal !== params) update(newVal);
125-
}, true);
126-
params = angular.copy(scope.$eval(ref.paramExpr));
141+
scope.$watch(ref.paramExpr, function(val) { if (val !== def.params) update(val); }, true);
142+
def.params = angular.copy(scope.$eval(ref.paramExpr));
127143
}
128144
update();
129145

130-
if (isForm) return;
131-
132-
element.bind("click", function(e) {
133-
var button = e.which || e.button;
134-
if ( !(button > 1 || e.ctrlKey || e.metaKey || e.shiftKey || element.attr('target')) ) {
135-
// HACK: This is to allow ng-clicks to be processed before the transition is initiated:
136-
var transition = $timeout(function() {
137-
$state.go(ref.state, params, options);
138-
});
139-
e.preventDefault();
140-
141-
// if the state has no URL, ignore one preventDefault from the <a> directive.
142-
var ignorePreventDefaultCount = isAnchor && !newHref ? 1: 0;
143-
e.preventDefault = function() {
144-
if (ignorePreventDefaultCount-- <= 0)
145-
$timeout.cancel(transition);
146-
};
147-
}
148-
});
146+
if (!type.clickable) return;
147+
element.bind("click", clickHook(element, $state, $timeout, type, function() { return def; }));
149148
}
150149
};
151150
}
152151

152+
/**
153+
* @ngdoc directive
154+
* @name ui.router.state.directive:ui-state
155+
*
156+
* @requires ui.router.state.uiSref
157+
*
158+
* @restrict A
159+
*
160+
* @description
161+
* Much like ui-sref, but will accept named $scope properties to evaluate for a state definition,
162+
* params and override options.
163+
*
164+
* @param {string} ui-state 'stateName' can be any valid absolute or relative state
165+
* @param {Object} ui-state-params params to pass to {@link ui.router.state.$state#href $state.href()}
166+
* @param {Object} ui-state-opts options to pass to {@link ui.router.state.$state#go $state.go()}
167+
*/
168+
$StateRefDynamicDirective.$inject = ['$state', '$timeout'];
169+
function $StateRefDynamicDirective($state, $timeout) {
170+
return {
171+
restrict: 'A',
172+
require: ['?^uiSrefActive', '?^uiSrefActiveEq'],
173+
link: function(scope, element, attrs, uiSrefActive) {
174+
var type = getTypeInfo(element);
175+
var active = uiSrefActive[1] || uiSrefActive[0];
176+
var group = [attrs.uiState, attrs.uiStateParams || null, attrs.uiStateOpts || null];
177+
var watch = '[' + group.map(function(val) { return val || 'null'; }).join(', ') + ']';
178+
var def = { state: null, params: null, options: null, href: null };
179+
180+
function runStateRefLink (group) {
181+
def.state = group[0]; def.params = group[1]; def.options = group[2];
182+
def.href = $state.href(def.state, def.params, def.options);
183+
184+
if (active) active.$$addStateInfo(ref.state, def.params);
185+
if (def.href) attrs.$set(type.attr, def.href);
186+
}
187+
188+
scope.$watch(watch, runStateRefLink, true);
189+
runStateRefLink(scope.$eval(watch));
190+
191+
if (!type.clickable) return;
192+
element.bind("click", clickHook(element, $state, $timeout, type, function() { return def; }));
193+
}
194+
};
195+
}
196+
197+
153198
/**
154199
* @ngdoc directive
155200
* @name ui.router.state.directive:ui-sref-active
@@ -269,18 +314,15 @@ function $StateRefActiveDirective($state, $stateParams, $interpolate) {
269314
}
270315

271316
function addClass(el, className) { $timeout(function () { el.addClass(className); }); }
272-
273317
function removeClass(el, className) { el.removeClass(className); }
274-
275318
function anyMatch(state, params) { return $state.includes(state.name, params); }
276-
277319
function exactMatch(state, params) { return $state.is(state.name, params); }
278-
279320
}]
280321
};
281322
}
282323

283324
angular.module('ui.router.state')
284325
.directive('uiSref', $StateRefDirective)
285326
.directive('uiSrefActive', $StateRefActiveDirective)
286-
.directive('uiSrefActiveEq', $StateRefActiveDirective);
327+
.directive('uiSrefActiveEq', $StateRefActiveDirective)
328+
.directive('uiState', $StateRefDynamicDirective);

test/stateDirectivesSpec.js

+65-27
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,71 @@ describe('uiStateRef', function() {
291291
}));
292292
});
293293

294+
describe('links with dynamic state definitions', function () {
295+
var template;
296+
297+
beforeEach(inject(function($rootScope, $compile, $state) {
298+
el = angular.element('<a ui-state="state" ui-state-params="params">state</a>');
299+
scope = $rootScope;
300+
angular.extend(scope, { state: 'contacts', params: {} });
301+
template = $compile(el)(scope);
302+
scope.$digest();
303+
}));
304+
305+
it('sets the correct initial href', function () {
306+
expect(angular.element(template[0]).attr('href')).toBe('#/contacts');
307+
});
308+
309+
it('updates to the new href', function () {
310+
expect(angular.element(template[0]).attr('href')).toBe('#/contacts');
311+
312+
scope.state = 'contacts.item';
313+
scope.params = { id: 5 };
314+
scope.$digest();
315+
expect(angular.element(template[0]).attr('href')).toBe('#/contacts/5');
316+
317+
scope.params.id = 25;
318+
scope.$digest();
319+
expect(angular.element(template[0]).attr('href')).toBe('#/contacts/25');
320+
});
321+
322+
it('retains the old href if the new points to a non-state', function () {
323+
expect(angular.element(template[0]).attr('href')).toBe('#/contacts');
324+
scope.state = 'nostate';
325+
scope.$digest();
326+
expect(angular.element(template[0]).attr('href')).toBe('#/contacts');
327+
});
328+
329+
it('accepts param overrides', inject(function ($compile) {
330+
el = angular.element('<a ui-state="state" ui-state-params="params">state</a>');
331+
scope.state = 'contacts.item';
332+
scope.params = { id: 10 };
333+
template = $compile(el)(scope);
334+
scope.$digest();
335+
expect(angular.element(template[0]).attr('href')).toBe('#/contacts/10');
336+
}));
337+
338+
it('accepts option overrides', inject(function ($compile, $timeout, $state) {
339+
var transitionOptions;
340+
341+
el = angular.element('<a ui-state="state" ui-state-opts="opts">state</a>');
342+
scope.state = 'contacts';
343+
scope.opts = { reload: true };
344+
template = $compile(el)(scope);
345+
scope.$digest();
346+
347+
spyOn($state, 'go').andCallFake(function(state, params, options) {
348+
transitionOptions = options;
349+
});
350+
351+
triggerClick(template)
352+
$timeout.flush();
353+
354+
expect(transitionOptions.reload).toEqual(true);
355+
expect(transitionOptions.absolute).toBeUndefined();
356+
}));
357+
});
358+
294359
describe('forms', function() {
295360
var el, scope;
296361

@@ -362,33 +427,6 @@ describe('uiStateRef', function() {
362427
expect($state.$current.name).toBe("contacts");
363428
}));
364429
});
365-
366-
describe('transition options', function() {
367-
368-
beforeEach(inject(function($rootScope, $compile, $state) {
369-
el = angular.element('<a ui-sref="contacts.item.detail({ id: contact.id })" ui-sref-opts="{ reload: true, absolute: true, notify: true }">Details</a>');
370-
scope = $rootScope;
371-
scope.contact = { id: 5 };
372-
373-
$compile(el)(scope);
374-
scope.$digest();
375-
}));
376-
377-
it('uses allowed transition options', inject(function($q, $timeout, $state) {
378-
var transitionOptions;
379-
380-
spyOn($state, 'go').andCallFake(function(state, params, options) {
381-
transitionOptions = options;
382-
});
383-
384-
triggerClick(el);
385-
$timeout.flush();
386-
387-
expect(transitionOptions.reload).toEqual(true);
388-
expect(transitionOptions.absolute).toEqual(true);
389-
expect(transitionOptions.notify).toBeUndefined();
390-
}));
391-
});
392430
});
393431

394432
describe('uiSrefActive', function() {

test/stateSpec.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,7 @@ describe('state', function () {
542542
$state.transitionTo('dynamicController', { type: "Acme" });
543543
$q.flush();
544544
expect(ctrlName).toEqual("AcmeFooController");
545-
}));+
545+
}));
546546

547547
it('uses the templateProvider to get template dynamically', inject(function ($state, $q) {
548548
$state.transitionTo('dynamicTemplate', { type: "Acme" });

0 commit comments

Comments
 (0)