Skip to content

Commit

Permalink
Merge pull request #635 from caitp/issue-19
Browse files Browse the repository at this point in the history
feat(uiStateActive): directive to add/remove classes for active state
  • Loading branch information
nateabele committed Dec 5, 2013
2 parents 0323fa4 + 4cea4ed commit ae360fc
Show file tree
Hide file tree
Showing 4 changed files with 203 additions and 44 deletions.
73 changes: 73 additions & 0 deletions src/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ function ancestors(first, second) {
return path;
}

/**
* IE8-safe wrapper for `Object.keys()`.
*
* @param {Object} object A JavaScript object.
* @return {Array} Returns the keys of the object as an array.
*/
function keys(object) {
if (Object.keys) {
return Object.keys(object);
}
var result = [];

angular.forEach(object, function(val, key) {
result.push(key);
});
return result;
}

/**
* IE8-safe wrapper for `Array.prototype.indexOf()`.
*
Expand Down Expand Up @@ -91,6 +109,61 @@ function inheritParams(currentParams, newParams, $current, $to) {
return extend({}, inherited, newParams);
}

/**
* Normalizes a set of values to string or `null`, filtering them by a list of keys.
*
* @param {Array} keys The list of keys to normalize/return.
* @param {Object} values An object hash of values to normalize.
* @return {Object} Returns an object hash of normalized string values.
*/
function normalize(keys, values) {
var normalized = {};

forEach(keys, function (name) {
var value = values[name];
normalized[name] = (value != null) ? String(value) : null;
});
return normalized;
}

/**
* Performs a non-strict comparison of the subset of two objects, defined by a list of keys.
*
* @param {Object} a The first object.
* @param {Object} b The second object.
* @param {Array} keys The list of keys within each object to compare. If the list is empty or not specified,
* it defaults to the list of keys in `a`.
* @return {Boolean} Returns `true` if the keys match, otherwise `false`.
*/
function equalForKeys(a, b, keys) {
if (!keys) {
keys = [];
for (var n in a) keys.push(n); // Used instead of Object.keys() for IE8 compatibility
}

for (var i=0; i<keys.length; i++) {
var k = keys[i];
if (a[k] != b[k]) return false; // Not '===', values aren't necessarily normalized
}
return true;
}

/**
* Returns the subset of an object, based on a list of keys.
*
* @param {Array} keys
* @param {Object} values
* @return {Boolean} Returns a subset of `values`.
*/
function filterByKeys(keys, values) {
var filtered = {};

forEach(keys, function (name) {
filtered[name] = values[name];
});
return filtered;
}

angular.module('ui.router.util', ['ng']);
angular.module('ui.router.router', ['ui.router.util']);
angular.module('ui.router.state', ['ui.router.router', 'ui.router.util']);
Expand Down
37 changes: 2 additions & 35 deletions src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -488,13 +488,13 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $
return url;
};

$state.get = function (stateOrName) {
$state.get = function (stateOrName, context) {
if (!isDefined(stateOrName)) {
var list = [];
forEach(states, function(state) { list.push(state.self); });
return list;
}
var state = findState(stateOrName);
var state = findState(stateOrName, context);
return (state && state.self) ? state.self : null;
};

Expand Down Expand Up @@ -546,39 +546,6 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $
return $state;
}

function normalize(keys, values) {
var normalized = {};

forEach(keys, function (name) {
var value = values[name];
normalized[name] = (value != null) ? String(value) : null;
});
return normalized;
}

function equalForKeys(a, b, keys) {
// If keys not provided, assume keys from object 'a'
if (!keys) {
keys = [];
for (var n in a) keys.push(n); // Used instead of Object.keys() for IE8 compatibility
}

for (var i=0; i<keys.length; i++) {
var k = keys[i];
if (a[k] != b[k]) return false; // Not '===', values aren't necessarily normalized
}
return true;
}

function filterByKeys(keys, values) {
var filtered = {};

forEach(keys, function (name) {
filtered[name] = values[name];
});
return filtered;
}

function shouldTriggerReload(to, from, locals, options) {
if ( to === from && ((locals === from.locals && !options.reload) || (to.self.reloadOnSearch === false)) ) {
return true;
Expand Down
61 changes: 52 additions & 9 deletions src/stateDirectives.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,25 @@ function parseStateRef(ref) {
return { state: parsed[1], paramExpr: parsed[3] || null };
}

function stateContext(el) {
var stateData = el.parent().inheritedData('$uiView');

if (stateData && stateData.state && stateData.state.name) {
return stateData.state;
}
}

$StateRefDirective.$inject = ['$state'];
function $StateRefDirective($state) {
return {
restrict: 'A',
link: function(scope, element, attrs) {
require: '?^uiSrefActive',
link: function(scope, element, attrs, uiSrefActive) {
var ref = parseStateRef(attrs.uiSref);
var params = null, url = null, base = $state.$current;
var params = null, url = null, base = stateContext(element) || $state.$current;
var isForm = element[0].nodeName === "FORM";
var attr = isForm ? "action" : "href", nav = true;

var stateData = element.parent().inheritedData('$uiView');

if (stateData && stateData.state && stateData.state.name) {
base = stateData.state;
}

var update = function(newVal) {
if (newVal) params = newVal;
if (!nav) return;
Expand All @@ -31,6 +34,9 @@ function $StateRefDirective($state) {
return false;
}
element[0][attr] = newHref;
if (uiSrefActive) {
uiSrefActive.$$setStateInfo(ref.state, params);
}
};

if (ref.paramExpr) {
Expand All @@ -57,4 +63,41 @@ function $StateRefDirective($state) {
};
}

angular.module('ui.router.state').directive('uiSref', $StateRefDirective);
$StateActiveDirective.$inject = ['$state', '$stateParams', '$interpolate'];
function $StateActiveDirective($state, $stateParams, $interpolate) {
return {
restrict: "A",
controller: function($scope, $element, $attrs) {
var state, params, activeClass;

// There probably isn't much point in $observing this
activeClass = $interpolate($attrs.uiSrefActive || '', false)($scope);

// Allow uiSref to communicate with uiSrefActive
this.$$setStateInfo = function(newState, newParams) {
state = $state.get(newState, stateContext($element));
params = newParams;
update();
};

$scope.$on('$stateChangeSuccess', update);

// Update route state
function update() {
if ($state.$current.self === state && matchesParams()) {
$element.addClass(activeClass);
} else {
$element.removeClass(activeClass);
}
}

function matchesParams() {
return !params || equalForKeys(params, $stateParams);
}
}
};
}

angular.module('ui.router.state')
.directive('uiSref', $StateRefDirective)
.directive('uiSrefActive', $StateActiveDirective);
76 changes: 76 additions & 0 deletions test/stateDirectivesSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,79 @@ describe('uiStateRef', function() {
}));
});
});

describe('uiSrefActive', function() {
var el, template, scope, document;

beforeEach(module('ui.router'));

beforeEach(module(function($stateProvider) {
$stateProvider.state('index', {
url: '',
}).state('contacts', {
url: '/contacts',
views: {
'@': {
template: '<a ui-sref=".item({ id: 6 })" ui-sref-active="active">Contacts</a>'
}
}
}).state('contacts.item', {
url: '/:id',
}).state('contacts.item.detail', {
url: '/detail/:foo'
});
}));

beforeEach(inject(function($document) {
document = $document[0];
}));

it('should update class for sibling uiSref', inject(function($rootScope, $q, $compile, $state) {
el = angular.element('<div><a ui-sref="contacts" ui-sref-active="active">Contacts</a></div>');
template = $compile(el)($rootScope);
$rootScope.$digest();

expect(angular.element(template[0].querySelector('a')).attr('class')).toBe('');
$state.transitionTo('contacts');
$q.flush();

expect(angular.element(template[0].querySelector('a')).attr('class')).toBe('active');

$state.transitionTo('contacts.item', { id: 5 });
$q.flush();
expect(angular.element(template[0].querySelector('a')).attr('class')).toBe('');
}));

it('should match state\'s parameters', inject(function($rootScope, $q, $compile, $state) {
el = angular.element('<div><a ui-sref="contacts.item.detail({ foo: \'bar\' })" ui-sref-active="active">Contacts</a></div>');
template = $compile(el)($rootScope);
$rootScope.$digest();

expect(angular.element(template[0].querySelector('a')).attr('class')).toBe('');
$state.transitionTo('contacts.item.detail', { id: 5, foo: 'bar' });
$q.flush();
expect(angular.element(template[0].querySelector('a')).attr('class')).toBe('active');

$state.transitionTo('contacts.item.detail', { id: 5, foo: 'baz' });
$q.flush();
expect(angular.element(template[0].querySelector('a')).attr('class')).toBe('');
}));

it('should resolve relative state refs', inject(function($rootScope, $q, $compile, $state) {
el = angular.element('<section><div ui-view></div></section>');
template = $compile(el)($rootScope);
$rootScope.$digest();

$state.transitionTo('contacts');
$q.flush();
expect(angular.element(template[0].querySelector('a')).attr('class')).toBe('ng-scope');

$state.transitionTo('contacts.item', { id: 6 });
$q.flush();
expect(angular.element(template[0].querySelector('a')).attr('class')).toBe('ng-scope active');

$state.transitionTo('contacts.item', { id: 5 });
$q.flush();
expect(angular.element(template[0].querySelector('a')).attr('class')).toBe('ng-scope');
}));
});

0 comments on commit ae360fc

Please sign in to comment.