Skip to content

feat(rating): backport from angular-ui/bootstrap, now using ngModel #255

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 5 additions & 11 deletions src/rating/docs/demo.html
Original file line number Diff line number Diff line change
@@ -1,21 +1,15 @@
<div ng-controller="RatingDemoCtrl">
<h4>Default</h4>
<rating value="rate" max="max" readonly="isReadonly" on-hover="hoveringOver(value)" on-leave="overStar = null"></rating>
<rating ng-model="rate" max="max" readonly="isReadonly" on-hover="hoveringOver(value)" on-leave="overStar = null" titles="['one','two','three']" ></rating>
<span class="label" ng-class="{'label-warning': percent<30, 'label-info': percent>=30 && percent<70, 'label-success': percent>=70}" ng-show="overStar && !isReadonly">{{percent}}%</span>

<pre style="margin:15px 0;">Rate: <b>{{rate}}</b> - Readonly is: <i>{{isReadonly}}</i> - Hovering over: <b>{{overStar || "none"}}</b></pre>

<button class="button small alert" ng-click="rate = 0" ng-disabled="isReadonly">Clear</button>
<button class="button small" ng-click="isReadonly = ! isReadonly">Toggle Readonly</button>
<button type="button" class="button small alert" ng-click="rate = 0" ng-disabled="isReadonly">Clear</button>
<button type="button" class="button small" ng-click="isReadonly = ! isReadonly">Toggle Readonly</button>
<hr />

<h4>Custom icons</h4>
<div ng-init="x = 5">
<rating value="x" max="15" state-on="'fa-check-circle'" state-off="'fa-check-circle-o'"></rating>
<b>(<i>Rate:</i> {{x}})</b>
</div>
<div ng-init="y = 2">
<rating value="y" rating-states="ratingStates"></rating>
<b>(<i>Rate:</i> {{y}})</b>
</div>
<div ng-init="x = 5"><rating ng-model="x" max="15" state-on="'fa-check-circle'" state-off="'fa-check-circle-o'"></rating> <b>(<i>Rate:</i> {{x}})</b></div>
<div ng-init="y = 2"><rating ng-model="y" rating-states="ratingStates"></rating> <b>(<i>Rate:</i> {{y}})</b></div>
</div>
8 changes: 6 additions & 2 deletions src/rating/docs/readme.md
Original file line number Diff line number Diff line change
@@ -6,18 +6,22 @@ It uses Font Awesome icons (http://fontawesome.io/) by default.

#### `<rating>` ####

* `value` <i class="fa fa-eye"></i>
* `ng-model` <i class="fa fa-eye"></i>
:
The current rate.

* `max`
_(Defaults: 5)_ :
Changes the number of icons.

* `readonly`
* `readonly` <i class="fa fa-eye"></i>
_(Defaults: false)_ :
Prevent user's interaction.

* `titles`
_(Defaults: ["one", "two", "three", "four", "five"])_ :
An array of Strings defining titles for all icons

* `on-hover(value)`
:
An optional expression called when user's mouse is over a particular icon.
88 changes: 58 additions & 30 deletions src/rating/rating.js
Original file line number Diff line number Diff line change
@@ -3,70 +3,98 @@ angular.module('mm.foundation.rating', [])
.constant('ratingConfig', {
max: 5,
stateOn: null,
stateOff: null
stateOff: null,
titles : ['one', 'two', 'three', 'four', 'five']
})

.controller('RatingController', ['$scope', '$attrs', '$parse', 'ratingConfig', function($scope, $attrs, $parse, ratingConfig) {
.controller('RatingController', ['$scope', '$attrs', 'ratingConfig', function($scope, $attrs, ratingConfig) {
var ngModelCtrl = { $setViewValue: angular.noop };

this.init = function(ngModelCtrl_) {
ngModelCtrl = ngModelCtrl_;
ngModelCtrl.$render = this.render;

ngModelCtrl.$formatters.push(function(value) {
if (angular.isNumber(value) && value << 0 !== value) {
value = Math.round(value);
}
return value;
});

this.maxRange = angular.isDefined($attrs.max) ? $scope.$parent.$eval($attrs.max) : ratingConfig.max;
this.stateOn = angular.isDefined($attrs.stateOn) ? $scope.$parent.$eval($attrs.stateOn) : ratingConfig.stateOn;
this.stateOff = angular.isDefined($attrs.stateOff) ? $scope.$parent.$eval($attrs.stateOff) : ratingConfig.stateOff;
var tmpTitles = angular.isDefined($attrs.titles) ? $scope.$parent.$eval($attrs.titles) : ratingConfig.titles ;
this.titles = angular.isArray(tmpTitles) && tmpTitles.length > 0 ?
tmpTitles : ratingConfig.titles;

this.createRateObjects = function(states) {
var defaultOptions = {
stateOn: this.stateOn,
stateOff: this.stateOff
};
var ratingStates = angular.isDefined($attrs.ratingStates) ?
$scope.$parent.$eval($attrs.ratingStates) :
new Array(angular.isDefined($attrs.max) ? $scope.$parent.$eval($attrs.max) : ratingConfig.max);
$scope.range = this.buildTemplateObjects(ratingStates);
};

for (var i = 0, n = states.length; i < n; i++) {
states[i] = angular.extend({ index: i }, defaultOptions, states[i]);
}
return states;
this.buildTemplateObjects = function(states) {
for (var i = 0, n = states.length; i < n; i++) {
states[i] = angular.extend({ index: i }, { stateOn: this.stateOn, stateOff: this.stateOff, title: this.getTitle(i) }, states[i]);
}
return states;
};

// Get objects used in template
$scope.range = angular.isDefined($attrs.ratingStates) ? this.createRateObjects(angular.copy($scope.$parent.$eval($attrs.ratingStates))): this.createRateObjects(new Array(this.maxRange));
this.getTitle = function(index) {
if (index >= this.titles.length) {
return index + 1;
} else {
return this.titles[index];
}
};

$scope.rate = function(value) {
if ( $scope.value !== value && !$scope.readonly ) {
$scope.value = value;
if (!$scope.readonly && value >= 0 && value <= $scope.range.length) {
ngModelCtrl.$setViewValue(ngModelCtrl.$viewValue === value ? 0 : value);
ngModelCtrl.$render();
}
};

$scope.enter = function(value) {
if ( ! $scope.readonly ) {
$scope.val = value;
if (!$scope.readonly) {
$scope.value = value;
}
$scope.onHover({value: value});
};

$scope.reset = function() {
$scope.val = angular.copy($scope.value);
$scope.value = ngModelCtrl.$viewValue;
$scope.onLeave();
};

$scope.$watch('value', function(value) {
$scope.val = value;
});
$scope.onKeydown = function(evt) {
if (/(37|38|39|40)/.test(evt.which)) {
evt.preventDefault();
evt.stopPropagation();
$scope.rate($scope.value + (evt.which === 38 || evt.which === 39 ? 1 : -1));
}
};

$scope.readonly = false;
if ($attrs.readonly) {
$scope.$parent.$watch($parse($attrs.readonly), function(value) {
$scope.readonly = !!value;
});
}
this.render = function() {
$scope.value = ngModelCtrl.$viewValue;
};
}])

.directive('rating', function() {
return {
restrict: 'EA',
require: ['rating', 'ngModel'],
scope: {
value: '=',
readonly: '=?',
onHover: '&',
onLeave: '&'
},
controller: 'RatingController',
templateUrl: 'template/rating/rating.html',
replace: true
replace: true,
link: function(scope, element, attrs, ctrls) {
var ratingCtrl = ctrls[0], ngModelCtrl = ctrls[1];
ratingCtrl.init( ngModelCtrl );
}
};
});
164 changes: 149 additions & 15 deletions src/rating/test/rating.spec.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
describe('rating directive', function () {
var $rootScope, element;
describe('rating directive', function() {
var $rootScope, $compile, element;
beforeEach(module('mm.foundation.rating'));
beforeEach(module('template/rating/rating.html'));
beforeEach(inject(function(_$compile_, _$rootScope_) {
$compile = _$compile_;
$rootScope = _$rootScope_;
$rootScope.rate = 3;
element = $compile('<rating value="rate"></rating>')($rootScope);
element = $compile('<rating ng-model="rate"></rating>')($rootScope);
$rootScope.$digest();
}));

@@ -27,24 +27,47 @@ describe('rating directive', function () {
return state;
}

function getTitles() {
var stars = getStars();
return stars.toArray().map(function(star) {
return angular.element(star).attr('title');
});
}

function triggerKeyDown(keyCode) {
var e = $.Event('keydown');
e.which = keyCode;
element.trigger(e);
}

it('contains the default number of icons', function() {
expect(getStars().length).toBe(5);
expect(element.attr('aria-valuemax')).toBe('5');
});

it('initializes the default star icons as selected', function() {
expect(getState()).toEqual([true, true, true, false, false]);
expect(element.attr('aria-valuenow')).toBe('3');
});

it('handles correctly the click event', function() {
getStar(2).click();
$rootScope.$digest();
expect(getState()).toEqual([true, true, false, false, false]);
expect($rootScope.rate).toBe(2);
expect(element.attr('aria-valuenow')).toBe('2');

getStar(5).click();
$rootScope.$digest();
expect(getState()).toEqual([true, true, true, true, true]);
expect($rootScope.rate).toBe(5);
expect(element.attr('aria-valuenow')).toBe('5');

getStar(5).click();
$rootScope.$digest();
expect(getState()).toEqual([false, false, false, false, false]);
expect($rootScope.rate).toBe(0);
expect(element.attr('aria-valuenow')).toBe('0');
});

it('handles correctly the hover event', function() {
@@ -63,30 +86,47 @@ describe('rating directive', function () {
expect($rootScope.rate).toBe(3);
});

it('rounds off the number of stars shown with decimal values', function() {
$rootScope.rate = 2.1;
$rootScope.$digest();

expect(getState()).toEqual([true, true, false, false, false]);
expect(element.attr('aria-valuenow')).toBe('2');

$rootScope.rate = 2.5;
$rootScope.$digest();

expect(getState()).toEqual([true, true, true, false, false]);
expect(element.attr('aria-valuenow')).toBe('3');
});

it('changes the number of selected icons when value changes', function() {
$rootScope.rate = 2;
$rootScope.$digest();

expect(getState()).toEqual([true, true, false, false, false]);
expect(element.attr('aria-valuenow')).toBe('2');
});

it('shows different number of icons when `max` attribute is set', function() {
element = $compile('<rating value="rate" max="7"></rating>')($rootScope);
element = $compile('<rating ng-model="rate" max="7"></rating>')($rootScope);
$rootScope.$digest();

expect(getStars().length).toBe(7);
expect(element.attr('aria-valuemax')).toBe('7');
});

it('shows different number of icons when `max` attribute is from scope variable', function() {
$rootScope.max = 15;
element = $compile('<rating value="rate" max="max"></rating>')($rootScope);
element = $compile('<rating ng-model="rate" max="max"></rating>')($rootScope);
$rootScope.$digest();
expect(getStars().length).toBe(15);
expect(element.attr('aria-valuemax')).toBe('15');
});

it('handles readonly attribute', function() {
$rootScope.isReadonly = true;
element = $compile('<rating value="rate" readonly="isReadonly"></rating>')($rootScope);
element = $compile('<rating ng-model="rate" readonly="isReadonly"></rating>')($rootScope);
$rootScope.$digest();

expect(getState()).toEqual([true, true, true, false, false]);
@@ -106,7 +146,7 @@ describe('rating directive', function () {

it('should fire onHover', function() {
$rootScope.hoveringOver = jasmine.createSpy('hoveringOver');
element = $compile('<rating value="rate" on-hover="hoveringOver(value)"></rating>')($rootScope);
element = $compile('<rating ng-model="rate" on-hover="hoveringOver(value)"></rating>')($rootScope);
$rootScope.$digest();

getStar(3).trigger('mouseover');
@@ -116,19 +156,67 @@ describe('rating directive', function () {

it('should fire onLeave', function() {
$rootScope.leaving = jasmine.createSpy('leaving');
element = $compile('<rating value="rate" on-leave="leaving()"></rating>')($rootScope);
element = $compile('<rating ng-model="rate" on-leave="leaving()"></rating>')($rootScope);
$rootScope.$digest();

element.trigger('mouseleave');
$rootScope.$digest();
expect($rootScope.leaving).toHaveBeenCalled();
});

describe('keyboard navigation', function() {
it('supports arrow keys', function() {
triggerKeyDown(38);
expect($rootScope.rate).toBe(4);

triggerKeyDown(37);
expect($rootScope.rate).toBe(3);
triggerKeyDown(40);
expect($rootScope.rate).toBe(2);

triggerKeyDown(39);
expect($rootScope.rate).toBe(3);
});

it('supports only arrow keys', function() {
$rootScope.rate = undefined;
$rootScope.$digest();

triggerKeyDown(36);
expect($rootScope.rate).toBe(undefined);

triggerKeyDown(41);
expect($rootScope.rate).toBe(undefined);
});

it('can get zero value but not negative', function() {
$rootScope.rate = 1;
$rootScope.$digest();

triggerKeyDown(37);
expect($rootScope.rate).toBe(0);

triggerKeyDown(37);
expect($rootScope.rate).toBe(0);
});

it('cannot get value above max', function() {
$rootScope.rate = 4;
$rootScope.$digest();

triggerKeyDown(38);
expect($rootScope.rate).toBe(5);

triggerKeyDown(38);
expect($rootScope.rate).toBe(5);
});
});

describe('custom states', function() {
beforeEach(inject(function() {
$rootScope.classOn = 'icon-ok-sign';
$rootScope.classOff = 'icon-ok-circle';
element = $compile('<rating value="rate" state-on="classOn" state-off="classOff"></rating>')($rootScope);
element = $compile('<rating ng-model="rate" state-on="classOn" state-off="classOff"></rating>')($rootScope);
$rootScope.$digest();
}));

@@ -145,12 +233,13 @@ describe('rating directive', function () {
{stateOn: 'heart'},
{stateOff: 'off'}
];
element = $compile('<rating value="rate" rating-states="states"></rating>')($rootScope);
element = $compile('<rating ng-model="rate" rating-states="states"></rating>')($rootScope);
$rootScope.$digest();
}));

it('should define number of icon elements', function () {
expect(getStars().length).toBe($rootScope.states.length);
it('should define number of icon elements', function() {
expect(getStars().length).toBe(4);
expect(element.attr('aria-valuemax')).toBe('4');
});

it('handles each icon', function() {
@@ -175,20 +264,65 @@ describe('rating directive', function () {
ratingConfig.max = 10;
ratingConfig.stateOn = 'on';
ratingConfig.stateOff = 'off';
element = $compile('<rating value="rate"></rating>')($rootScope);
element = $compile('<rating ng-model="rate"></rating>')($rootScope);
$rootScope.$digest();
}));
afterEach(inject(function(ratingConfig) {
// return it to the original state
angular.extend(ratingConfig, originalConfig);
}));

it('should change number of icon elements', function () {
it('should change number of icon elements', function() {
expect(getStars().length).toBe(10);
});

it('should change icon states', function () {
it('should change icon states', function() {
expect(getState('on', 'off')).toEqual([true, true, true, true, true, false, false, false, false, false]);
});
});

describe('Default title', function() {
it('should return the default title for each star', function() {
expect(getTitles()).toEqual(['one', 'two', 'three', 'four', 'five']);
});
});

describe('shows different title when `max` attribute is greater than the titles array ', function() {
var originalConfig = {};
beforeEach(inject(function(ratingConfig) {
$rootScope.rate = 5;
angular.extend(originalConfig, ratingConfig);
ratingConfig.max = 10;
element = $compile('<rating ng-model="rate"></rating>')($rootScope);
$rootScope.$digest();
}));
afterEach(inject(function(ratingConfig) {
// return it to the original state
angular.extend(ratingConfig, originalConfig);
}));

it('should return the default title for each star', function() {
expect(getTitles()).toEqual(['one', 'two', 'three', 'four', 'five', '6', '7', '8', '9', '10']);
});
});

describe('shows custom titles ', function() {
it('should return the custom title for each star', function() {
$rootScope.titles = [44,45,46];
element = $compile('<rating ng-model="rate" titles="titles"></rating>')($rootScope);
$rootScope.$digest();
expect(getTitles()).toEqual(['44', '45', '46', '4', '5']);
});
it('should return the default title if the custom title is empty', function() {
$rootScope.titles = [];
element = $compile('<rating ng-model="rate" titles="titles"></rating>')($rootScope);
$rootScope.$digest();
expect(getTitles()).toEqual(['one', 'two', 'three', 'four', 'five']);
});
it('should return the default title if the custom title is not an array', function() {
element = $compile('<rating ng-model="rate" titles="test"></rating>')($rootScope);
$rootScope.$digest();
expect(getTitles()).toEqual(['one', 'two', 'three', 'four', 'five']);
});
});
});
6 changes: 3 additions & 3 deletions template/rating/rating.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<span ng-mouseleave="reset()">
<i ng-repeat="r in range" ng-mouseenter="enter($index + 1)" ng-click="rate($index + 1)" class="fa"
ng-class="$index < val && (r.stateOn || 'fa-star') || (r.stateOff || 'fa-star-o')"></i>
<span ng-mouseleave="reset()" ng-keydown="onKeydown($event)" tabindex="0" role="slider" aria-valuemin="0" aria-valuemax="{{range.length}}" aria-valuenow="{{value}}">
<span ng-repeat-start="r in range track by $index" class="show-for-sr">({{ $index < value ? '*' : ' ' }})</span>
<i ng-repeat-end ng-mouseenter="enter($index + 1)" ng-click="rate($index + 1)" class="fa" ng-class="$index < value && (r.stateOn || 'fa-star') || (r.stateOff || 'fa-star-o')" ng-attr-title="{{r.title}}" ></i>
</span>