/**
* The Rating Utility - The Star Rating creates a non-obstrusive star rating
* control, could be based on a set of radio input boxes.
*
* @module aui-rating
*/
var Lang = A.Lang,
isBoolean = Lang.isBoolean,
isNumber = Lang.isNumber,
isString = Lang.isString,
ENTER = 13,
getCN = A.getClassName,
CSS_ICON = getCN('glyphicon'),
CSS_STAR = getCN('glyphicon', 'star'),
CSS_STAR_EMPTY = getCN('glyphicon', 'star', 'empty'),
CSS_RATING_LABEL = getCN('rating', 'label'),
TPL_ELEMENT = '<a class="{cssClasses}" tabindex="{tabindex}"></a>',
TPL_LABEL = '<span class="' + CSS_RATING_LABEL + '"></span>';
/**
* A base class for Rating, providing:
*
* - A non-obstrusive star rating control
* - Could be based on a set of radio input boxes
*
* Check the [live demo](http://alloyui.com/examples/rating/).
*
* @class A.Rating
* @extends A.Component
* @param {Object} config Object literal specifying widget configuration
* properties.
* @constructor
* @example
```
<div id="myRating"></div>
```
* @example
```
YUI().use(
'aui-rating',
function(Y) {
new Y.ThumbRating(
{
boundingBox: '#myRating'
}
).render();
}
);
```
*/
var Rating = A.Component.create({
/**
* Static property provides a string to identify the class.
*
* @property NAME
* @type String
* @static
*/
NAME: 'rating',
/**
* Static property used to define the default attribute
* configuration for the Rating.
*
* @property ATTRS
* @type Object
* @static
*/
ATTRS: {
/**
* Whether the Rating is disabled or not.
* Disabled Ratings don't allow hover or click,
* just display selected stars.
*
* @attribute disabled
* @default false
* @type Boolean
*/
disabled: {
value: false,
validator: isBoolean
},
/**
* If `true` could be reseted
* (i.e., have no values selected).
*
* @attribute canReset
* @default true
* @type Boolean
*/
canReset: {
value: true,
validator: isBoolean
},
/**
* CSS classes applied on Rating.
*
* @attribute cssClasses
* @type Object
*/
cssClasses: {
value: {
element: [CSS_ICON, CSS_STAR_EMPTY].join(' '),
hover: [CSS_ICON, CSS_STAR].join(' '),
off: [CSS_ICON, CSS_STAR_EMPTY].join(' '),
on: [CSS_ICON, CSS_STAR].join(' ')
}
},
/**
* The number of selected starts when the Rating render.
*
* @attribute defaultSelected
* @default 0
* @writeOnce
* @type Number
*/
defaultSelected: {
value: 0,
writeOnce: true,
validator: isNumber
},
/**
* [NodeList](NodeList.html) of elements used on the
* Rating. Each element is one Star.
*
* @attribute elements
* @writeOnce
* @readOnly
* @type NodeList
*/
elements: {
validator: A.Lang.isNodeList
},
/**
* Hidden input to handle the selected value. This hidden input
* replace the radio elements and keep the same name.
*
* @attribute hiddenInput
* @type Node
*/
hiddenInput: {
validator: A.Lang.isNode
},
/**
* Name of the [hiddenInput](A.Rating.html#attr_hiddenInput) element. If
* not specified will use the name of the replaced radio.
*
* @attribute inputName
* @default ''
* @type String
*/
inputName: {
value: '',
validator: isString
},
/**
* Label to be displayed with the Rating elements.
*
* @attribute label
* @default ''
* @type String
*/
label: {
value: '',
validator: isString
},
/**
* DOM Node to display the text of the StarRating. If not
* specified try to query using HTML_PARSER an element inside
* boundingBox which matches `aui-rating-label-element`.
*
* @attribute labelNode
* @default Generated div element.
* @type String
*/
labelNode: {
valueFn: function() {
return A.Node.create(TPL_LABEL);
},
validator: A.Lang.isNode
},
/**
* Stores the index of the selected element.
*
* @attribute selectedIndex
* @default -1
* @type Number
*/
selectedIndex: {
value: -1,
validator: isNumber
},
/**
* If `true` will extract the value of the
* `title` attribute on the radio, and use it on the
* generated Rating elements.
*
* @attribute showTitle
* @default true
* @type boolean
*/
showTitle: {
value: true,
validator: isBoolean
},
/**
* Number of Rating elements to be displayed.
*
* @attribute size
* @default 5
* @type Number
*/
size: {
value: 5,
validator: function(v) {
return isNumber(v) && (v > 0);
}
},
/**
* If set, will be used when there is no DOM `title` on the
* radio elements.
*
* @attribute title
* @default null
* @type String
*/
title: null,
/**
* Stores the value of the current selected Rating element.
*
* @attribute value
* @default null
* @type String
*/
value: null
},
/**
* Object hash, defining how attribute values are to be parsed from
* markup contained in the widget's content box.
*
* @property HTML_PARSER
* @type Object
* @static
*/
HTML_PARSER: {
elements: function(srcNode) {
return srcNode.all('a');
},
label: function(srcNode) {
var labelNode = srcNode.one('.' + CSS_RATING_LABEL);
if (labelNode) {
return labelNode.html();
}
},
labelNode: '.' + CSS_RATING_LABEL
},
prototype: {
BOUNDING_TEMPLATE: '<span></span>',
CONTENT_TEMPLATE: '<span></span>',
/**
* Construction logic executed during Rating instantiation. Lifecycle.
*
* @method initializer
* @protected
*/
initializer: function() {
var instance = this;
instance.inputElementsData = {};
instance.after('labelChange', this._afterSetLabel);
},
/**
* Create the DOM structure for the Rating. Lifecycle.
*
* @method renderUI
* @protected
*/
renderUI: function() {
var instance = this;
instance._parseInputElements();
instance._renderLabel();
instance._renderElements();
},
/**
* Bind the events on the Rating UI. Lifecycle.
*
* @method bindUI
* @protected
*/
bindUI: function() {
var instance = this;
instance._createEvents();
instance.on({
click: instance._handleClickEvent,
keypress: instance._handleKeyPressEvent,
mouseover: instance._handleMouseOverEvent,
mouseout: instance._handleMouseOutEvent
});
},
/**
* Sync the Rating UI. Lifecycle.
*
* @method syncUI
* @protected
*/
syncUI: function() {
var instance = this;
instance._syncElements();
instance._syncLabelUI();
},
/**
* Clear all selected starts to the default state.
*
* @method clearSelection
*/
clearSelection: function() {
var instance = this,
cssClasses = instance.get('cssClasses');
instance.get('elements').each(function(node) {
node.removeClass(cssClasses.on);
node.removeClass(cssClasses.hover);
node.addClass(cssClasses.element);
});
},
/**
* Select the `index` Rating element.
*
* @method select
* @param {Number} index Index to be selected
*/
select: function(index) {
var instance = this,
oldIndex = instance.get('selectedIndex'),
canReset = instance.get('canReset');
// clear selection when the selected element is clicked
if (canReset && (oldIndex === index)) {
index = -1;
}
instance.set('selectedIndex', index);
var selectedIndex = instance.get('selectedIndex'),
data = instance._getInputData(selectedIndex),
title = ('title' in data) ? data.title : '',
value = ('value' in data) ? data.value : selectedIndex;
instance.fillTo(selectedIndex);
instance.set('title', title);
instance.set('value', value);
var hiddenInput = instance.get('hiddenInput');
hiddenInput.setAttribute('title', title);
hiddenInput.setAttribute('value', value);
},
/**
* Add the `className` on the the `index` element
* and all the previous Rating elements.
*
* @method fillTo
* @param {Number} index Index to be selected
* @param {String} className Class name to be applied when fill the
* Rating elements
*/
fillTo: function(index, className) {
var instance = this,
cssClasses = instance.get('cssClasses');
instance.clearSelection();
if (index >= 0) {
instance.get('elements').some(function(node, i) {
node.addClass(className || cssClasses.on);
node.removeClass(cssClasses.element);
return (i === Math.round(index));
});
}
},
/**
* Find the index of the `elem`.
*
* @method indexOf
* @param {Node} elem Rating element
* @return {Number}
*/
indexOf: function(elem) {
var instance = this;
return instance.get('elements').indexOf(elem);
},
/**
* Check if the Rating element can fire the custom events. Disabled
* elements won't fire nothing.
*
* @method _canFireCustomEvent
* @param {EventFacade} event
* @protected
* @return {Boolean}
*/
_canFireCustomEvent: function(event) {
var instance = this,
domTarget = event.domEvent.target;
// checks if the widget is not disabled and if the dom event is
// firing with a item as target do not fire custom events for other
// elements into the boundingBox
return !instance.get('disabled') && domTarget.test('a');
},
/**
* Create rating elements based on the `size`
* attribute. It's only invoked when the HTML_PARSER does not find
* nothing.
*
* @method _createElements
* @protected
* @return {NodeList}
*/
_createElements: function() {
var instance = this,
cssClasses = instance.get('cssClasses'),
disabled,
elements = [],
size = this.get('size');
disabled = instance.get('disabled');
for (var i = 0; i < size; i++) {
elements.push(
Lang.sub(TPL_ELEMENT, {
cssClasses: cssClasses.element,
tabindex: disabled ? -1 : 0
})
);
}
return A.NodeList.create(elements.join(''));
},
/**
* Create the custom events.
*
* @method _createEvents
* @protected
*/
_createEvents: function() {
var instance = this;
// create publish function for kweight optimization
var publish = function(name, fn) {
instance.publish(name, {
defaultFn: fn,
queuable: false,
emitFacade: true,
bubbles: true
});
};
/**
* Handle the itemClick event.
*
* @event itemClick
* @preventable _defRatingItemClickFn
* @param {EventFacade} event The itemClick event.
* @type {EventCustom}
*/
publish(
'itemClick',
this._defRatingItemClickFn
);
/**
* Handle the itemSelect event.
*
* @event itemSelect
* @preventable _defRatingItemSelectFn
* @param {EventFacade} event The itemSelect event.
* @type {EventCustom}
*/
publish(
'itemSelect',
this._defRatingItemSelectFn
);
/**
* Handle the itemOver event.
*
* @event itemSelect
* @preventable _defRatingItemOverFn
* @param {EventFacade} event The itemOver event.
* @type {EventCustom}
*/
publish(
'itemOver',
this._defRatingItemOverFn
);
/**
* Handle the itemOut event.
*
* @event itemOut
* @preventable _defRatingItemOutFn
* @param {EventFacade} event The itemOut event.
* @type {EventCustom}
*/
publish(
'itemOut',
this._defRatingItemOutFn
);
},
/**
* Fire the itemClick event.
*
* @method _defRatingItemClickFn
* @param {EventFacade} event itemClick event facade
* @protected
*/
_defRatingItemClickFn: function(event) {
var instance = this,
domEvent = event.domEvent;
instance.fire('itemSelect', {
delegateEvent: event,
domEvent: domEvent,
ratingItem: domEvent.target
});
},
/**
* Fire the itemSelect event.
*
* @method _defRatingItemSelectFn
* @param {EventFacade} event itemSelect event facade
* @protected
*/
_defRatingItemSelectFn: function(event) {
var instance = this,
domTarget = event.domEvent.target;
instance.select(
instance.indexOf(domTarget)
);
},
/**
* Fire the itemOut event.
*
* @method _defRatingItemOutFn
* @param {EventFacade} event itemOut event facade
* @protected
*/
_defRatingItemOutFn: function() {
var instance = this;
instance.fillTo(instance.get('selectedIndex'));
},
/**
* Fire the itemOver event.
*
* @method _defRatingItemOverFn
* @param {EventFacade} event itemOver event facade
* @protected
*/
_defRatingItemOverFn: function(event) {
var instance = this,
cssClasses = instance.get('cssClasses'),
index = instance.indexOf(event.domEvent.target);
instance.fillTo(index, cssClasses.hover);
},
/**
* Handle the keypress event. If the pressed key is Enter, fires `itemClick` event,
* i.e. does the same as if the user has clicked with the mouse.
*
* @method _handleKeyPressEvent
* @param {EventFacade} event
* @protected
*/
_handleKeyPressEvent: function(event) {
var instance = this,
domEvent;
domEvent = event.domEvent;
if (domEvent.charCode === ENTER) {
instance._handleClickEvent(event);
}
},
/**
* Parse the HTML radio elements from the markup to be Rating elements.
*
* @method _parseInputElements
* @protected
*/
_parseInputElements: function() {
var instance = this,
boundingBox = instance.get('boundingBox'),
inputs = boundingBox.all('input'),
size = inputs.size(),
inputName = instance.get('inputName'),
hiddenInput = A.Node.create('<input type="hidden" />');
if (size > 0) {
inputName = inputName || inputs.item(0).getAttribute('name');
instance.set('size', size);
var labels = boundingBox.getElementsByTagName('label');
inputs.each(function(node, index) {
var id = node.get('id');
var label = '';
if (id) {
// for a11y parse the <label> elments information
// checking if the node has a <label>
var labelEl = labels.filter('[for="' + id + '"]');
if (labelEl.size()) {
// if there is, extract the content of the label to
// use as content of the anchors...
label = labelEl.item(0).html();
}
}
instance.inputElementsData[index] = {
content: label,
value: node.getAttribute('value') || index,
title: node.getAttribute('title')
};
});
labels.remove(true);
inputs.remove(true);
}
if (inputName) {
hiddenInput.setAttribute('name', inputName);
boundingBox.appendChild(hiddenInput);
}
instance.set('hiddenInput', hiddenInput);
},
/**
* Render the Rating label.
*
* @method _renderLabel
* @protected
*/
_renderLabel: function() {
var instance = this;
instance.get('contentBox').setContent(
instance.get('labelNode')
);
},
/**
* Render the Rating elements.
*
* @method _renderElements
* @protected
*/
_renderElements: function() {
var instance = this,
contentBox = instance.get('contentBox'),
elements = instance.get('elements');
// if there are no elements in the markup, create them based on the
// size attribute
if (!elements || !elements.size()) {
elements = instance._createElements();
instance.set('elements', elements);
}
elements.each(
function(element, i) {
var data = instance._getInputData(i),
content = data.content,
// try to use the pulled title data from the dom,
// otherwise use the TITLE attr, in the last case use
// the content
title = data.title || instance.get('title') || content;
// setting the title
if (title && instance.get('showTitle')) {
element.setAttribute('title', title);
}
if (!element.attr('href') && (element.get('nodeName').toLowerCase() === 'a')) {
element.setAttribute('onclick', 'return false;');
}
}
);
contentBox.append(elements.getDOM());
},
/**
* Sync the Rating elements.
*
* @method _syncElements
* @protected
*/
_syncElements: function() {
var instance = this,
selectedIndex = instance.get('defaultSelected') - 1;
instance.set('selectedIndex', selectedIndex);
instance.select();
},
/**
* Sync the Rating label UI.
*
* @method _syncLabelUI
* @protected
*/
_syncLabelUI: function() {
var instance = this,
labelText = instance.get('label');
instance.get('labelNode').html(labelText);
},
/**
* Get the `index` element input data stored on `inputElementsData`.
*
* @method _getInputData
* @protected
*/
_getInputData: function(index) {
var instance = this;
return instance.inputElementsData[index] || {};
},
/**
* Fire the click event.
*
* @method _handleClickEvent
* @param {EventFacade} event Click event facade
* @protected
*/
_handleClickEvent: function(event) {
var instance = this,
domEvent = event.domEvent;
if (instance._canFireCustomEvent(event)) {
instance.fire('itemClick', {
delegateEvent: event,
domEvent: domEvent
});
}
else {
domEvent.preventDefault();
}
},
/**
* Fire the mouseOut event.
*
* @method _handleMouseOutEvent
* @param {EventFacade} event mouseOut event facade
* @protected
*/
_handleMouseOutEvent: function(event) {
var instance = this;
if (instance._canFireCustomEvent(event)) {
instance.fire('itemOut', {
delegateEvent: event,
domEvent: event.domEvent
});
}
},
/**
* Fire the mouseOver event.
*
* @method _handleMouseOverEvent
* @param {EventFacade} event mouseOver event facade
* @protected
*/
_handleMouseOverEvent: function(event) {
var instance = this;
if (instance._canFireCustomEvent(event)) {
instance.fire('itemOver', {
delegateEvent: event,
domEvent: event.domEvent
});
}
},
/**
* Fire after the value of the [label](A.Rating.html#attr_label)
* attribute change.
*
* @method _afterSetLabel
* @param {EventFacade} event
* @protected
*/
_afterSetLabel: function() {
this._syncLabelUI();
}
}
});
A.Rating = Rating;
A.StarRating = Rating;