/**
* A ratings picker based on `Ext.Gadget`.
*
* @example
* Ext.create({
* xtype: 'rating',
* renderTo: Ext.getBody(),
* listeners: {
* change: function (picker, value) {
* console.log('Rating ' + value);
* }
* }
* });
*/
Ext.define('Ext.ux.rating.Picker', {
extend: 'Ext.Gadget',
xtype: 'rating',
focusable: true,
/*
* The "cachedConfig" block is basically the same as "config" except that these
* values are applied specially to the first instance of the class. After processing
* these configs, the resulting values are stored on the class `prototype` and the
* template DOM element also reflects these default values.
*/
cachedConfig: {
/**
* @cfg {String} [family]
* The CSS `font-family` to use for displaying the `{@link #glyphs}`.
*/
family: 'monospace',
/**
* @cfg {String/String[]/Number[]} [glyphs]
* Either a string containing the two glyph characters, or an array of two strings
* containing the individual glyph characters or an array of two numbers with the
* character codes for the individual glyphs.
*
* For example:
*
* @example
* Ext.create({
* xtype: 'rating',
* renderTo: Ext.getBody(),
* glyphs: [ 9671, 9670 ], // '◇◆',
* listeners: {
* change: function (picker, value) {
* console.log('Rating ' + value);
* }
* }
* });
*/
glyphs: '☆★',
/**
* @cfg {Number} [minimum=1]
* The minimum allowed `{@link #value}` (rating).
*/
minimum: 1,
/**
* @cfg {Number} [limit]
* The maximum allowed `{@link #value}` (rating).
*/
limit: 5,
/**
* @cfg {String/Object} [overStyle]
* Optional styles to apply to the rating glyphs when `{@link #trackOver}` is
* enabled.
*/
overStyle: null,
/**
* @cfg {Number} [rounding=1]
* The rounding to apply to values. Common choices are 0.5 (for half-steps) or
* 0.25 (for quarter steps).
*/
rounding: 1,
/**
* @cfg {String} [scale="125%"]
* The CSS `font-size` to apply to the glyphs. This value defaults to 125% because
* glyphs in the stock font tend to be too small. When using specially designed
* "icon fonts" you may want to set this to 100%.
*/
scale: '125%',
/**
* @cfg {String/Object} [selectedStyle]
* Optional styles to apply to the rating value glyphs.
*/
selectedStyle: null,
/**
* @cfg {Object/String/String[]/Ext.XTemplate/Function} tip
* A template or a function that produces the tooltip text. The `Object`, `String`
* and `String[]` forms are converted to an `Ext.XTemplate`. If a function is given,
* it will be called with an object parameter and should return the tooltip text.
* The object contains these properties:
*
* - component: The rating component requesting the tooltip.
* - tracking: The current value under the mouse cursor.
* - trackOver: The value of the `{@link #trackOver}` config.
* - value: The current value.
*
* Templates can use these properties to generate the proper text.
*/
tip: null,
/**
* @cfg {Boolean} [trackOver=true]
* Determines if mouse movements should temporarily update the displayed value.
* The actual `value` is only updated on `click` but this rather acts as the
* "preview" of the value prior to click.
*/
trackOver: true,
/**
* @cfg {Number} value
* The rating value. This value is bounded by `minimum` and `limit` and is also
* adjusted by the `rounding`.
*/
value: null,
//---------------------------------------------------------------------
// Private configs
/**
* @cfg {String} tooltipText
* The current tooltip text. This value is set into the DOM by the updater (hence
* only when it changes). This is intended for use by the tip manager
* (`{@link Ext.tip.QuickTipManager}`). Developers should never need to set this
* config since it is handled by virtue of setting other configs (such as the
* {@link #tooltip} or the {@link #value}.).
* @private
*/
tooltipText: null,
/**
* @cfg {Number} trackingValue
* This config is used to when `trackOver` is `true` and represents the tracked
* value. This config is maintained by our `mousemove` handler. This should not
* need to be set directly by user code.
* @private
*/
trackingValue: null
},
config: {
/**
* @cfg {Boolean/Object} [animate=false]
* Specifies an animation to use when changing the `{@link #value}`. When setting
* this config, it is probably best to set `{@link #trackOver}` to `false`.
*/
animate: null
},
// This object describes our element tree from the root.
element: {
cls: 'u' + Ext.baseCSSPrefix + 'rating-picker',
// Since we are replacing the entire "element" tree, we have to assign this
// "reference" as would our base class.
reference: 'element',
children: [{
reference: 'innerEl',
cls: 'u' + Ext.baseCSSPrefix + 'rating-picker-inner',
listeners: {
click: 'onClick',
mousemove: 'onMouseMove',
mouseenter: 'onMouseEnter',
mouseleave: 'onMouseLeave'
},
children: [{
reference: 'valueEl',
cls: 'u' + Ext.baseCSSPrefix + 'rating-picker-value'
}, {
reference: 'trackerEl',
cls: 'u' + Ext.baseCSSPrefix + 'rating-picker-tracker'
}]
}]
},
// Tell the Binding system to default to our "value" config.
defaultBindProperty: 'value',
// Enable two-way data binding for the "value" config.
twoWayBindable: 'value',
overCls: 'u' + Ext.baseCSSPrefix + 'rating-picker-over',
trackOverCls: 'u' + Ext.baseCSSPrefix + 'rating-picker-track-over',
//-------------------------------------------------------------------------
// Config Appliers
applyGlyphs: function(value) {
if (typeof value === 'string') {
//<debug>
if (value.length !== 2) {
Ext.raise('Expected 2 characters for "glyphs" not "' + value + '".');
}
//</debug>
value = [ value.charAt(0), value.charAt(1) ];
}
else if (typeof value[0] === 'number') {
value = [
String.fromCharCode(value[0]),
String.fromCharCode(value[1])
];
}
return value;
},
applyOverStyle: function(style) {
this.trackerEl.applyStyles(style);
},
applySelectedStyle: function(style) {
this.valueEl.applyStyles(style);
},
applyTip: function(tip) {
if (tip && typeof tip !== 'function') {
if (!tip.isTemplate) {
tip = new Ext.XTemplate(tip);
}
tip = tip.apply.bind(tip);
}
return tip;
},
applyTrackingValue: function(value) {
return this.applyValue(value); // same rounding as normal value
},
applyValue: function(v) {
var rounding, limit, min;
if (v !== null) {
rounding = this.getRounding();
limit = this.getLimit();
min = this.getMinimum();
v = Math.round(Math.round(v / rounding) * rounding * 1000) / 1000;
v = (v < min) ? min : (v > limit ? limit : v);
}
return v;
},
//-------------------------------------------------------------------------
// Event Handlers
onClick: function(event) {
var value = this.valueFromEvent(event);
this.setValue(value);
},
onMouseEnter: function() {
this.element.addCls(this.overCls);
},
onMouseLeave: function() {
this.element.removeCls(this.overCls);
},
onMouseMove: function(event) {
var value = this.valueFromEvent(event);
this.setTrackingValue(value);
},
//-------------------------------------------------------------------------
// Config Updaters
updateFamily: function(family) {
this.element.setStyle('fontFamily', "'" + family + "'");
},
updateGlyphs: function() {
this.refreshGlyphs();
},
updateLimit: function() {
this.refreshGlyphs();
},
updateScale: function(size) {
this.element.setStyle('fontSize', size);
},
updateTip: function() {
this.refreshTip();
},
updateTooltipText: function(text) {
this.setTooltip(text); // modern only (replaced by classic override)
},
updateTrackingValue: function(value) {
var me = this,
trackerEl = me.trackerEl,
newWidth = me.valueToPercent(value);
trackerEl.setStyle('width', newWidth);
me.refreshTip();
},
updateTrackOver: function(trackOver) {
this.element.toggleCls(this.trackOverCls, trackOver);
},
updateValue: function(value, oldValue) {
var me = this,
animate = me.getAnimate(),
valueEl = me.valueEl,
newWidth = me.valueToPercent(value),
column, record;
if (me.isConfiguring || !animate) {
valueEl.setStyle('width', newWidth);
}
else {
valueEl.stopAnimation();
valueEl.animate(Ext.merge({
from: { width: me.valueToPercent(oldValue) },
to: { width: newWidth }
}, animate));
}
me.refreshTip();
if (!me.isConfiguring) {
// Since we are (re)configured many times as we are used in a grid cell, we
// avoid firing the change event unless there are listeners.
if (me.hasListeners.change) {
me.fireEvent('change', me, value, oldValue);
}
column = me.getWidgetColumn && me.getWidgetColumn();
record = column && me.getWidgetRecord && me.getWidgetRecord();
if (record && column.dataIndex) {
// When used in a widgetcolumn, we should update the backing field. The
// linkages will be cleared as we are being recycled, so this will only
// reach this line when we are properly attached to a record and the
// change is coming from the user (or a call to setValue).
record.set(column.dataIndex, value);
}
}
},
//-------------------------------------------------------------------------
// Config System Optimizations
//
// These are to deal with configs that combine to determine what should be
// rendered in the DOM. For example, "glyphs" and "limit" must both be known
// to render the proper text nodes. The "tip" and "value" likewise are
// used to update the tooltipText.
//
// To avoid multiple updates to the DOM (one for each config), we simply mark
// the rendering as invalid and post-process these flags on the tail of any
// bulk updates.
afterCachedConfig: function() {
// Now that we are done setting up the initial values we need to refresh the
// DOM before we allow Ext.Widget's implementation to cloneNode on it.
this.refresh();
return this.callParent(arguments);
},
initConfig: function(instanceConfig) {
this.isConfiguring = true;
this.callParent([ instanceConfig ]);
// The firstInstance will already have refreshed the DOM (in afterCacheConfig)
// but all instances beyond the first need to refresh if they have custom values
// for one or more configs that affect the DOM (such as "glyphs" and "limit").
this.refresh();
},
setConfig: function() {
var me = this;
// Since we could be updating multiple configs, save any updates that need
// multiple values for afterwards.
me.isReconfiguring = true;
me.callParent(arguments);
me.isReconfiguring = false;
// Now that all new values are set, we can refresh the DOM.
me.refresh();
return me;
},
//-------------------------------------------------------------------------
privates: {
/**
* This method returns the DOM text node into which glyphs are placed.
* @param {HTMLElement} dom The DOM node parent of the text node.
* @return {HTMLElement} The text node.
* @private
*/
getGlyphTextNode: function(dom) {
var node = dom.lastChild;
// We want all our text nodes to be at the end of the child list, most
// especially the text node on the innerEl. That text node affects the
// default left/right position of our absolutely positioned child divs
// (trackerEl and valueEl).
if (!node || node.nodeType !== 3) {
node = dom.ownerDocument.createTextNode('');
dom.appendChild(node);
}
return node;
},
getTooltipData: function() {
var me = this;
return {
component: me,
tracking: me.getTrackingValue(),
trackOver: me.getTrackOver(),
value: me.getValue()
};
},
/**
* Forcibly refreshes both glyph and tooltip rendering.
* @private
*/
refresh: function() {
var me = this;
if (me.invalidGlyphs) {
me.refreshGlyphs(true);
}
if (me.invalidTip) {
me.refreshTip(true);
}
},
/**
* Refreshes the glyph text rendering unless we are currently performing a
* bulk config change (initConfig or setConfig).
* @param {Boolean} now Pass `true` to force the refresh to happen now.
* @private
*/
refreshGlyphs: function(now) {
var me = this,
later = !now && (me.isConfiguring || me.isReconfiguring),
el, glyphs, limit, on, off, trackerEl, valueEl;
if (!later) {
el = me.getGlyphTextNode(me.innerEl.dom);
valueEl = me.getGlyphTextNode(me.valueEl.dom);
trackerEl = me.getGlyphTextNode(me.trackerEl.dom);
glyphs = me.getGlyphs();
limit = me.getLimit();
for (on = off = ''; limit--;) {
off += glyphs[0];
on += glyphs[1];
}
el.nodeValue = off;
valueEl.nodeValue = on;
trackerEl.nodeValue = on;
}
me.invalidGlyphs = later;
},
/**
* Refreshes the tooltip text rendering unless we are currently performing a
* bulk config change (initConfig or setConfig).
* @param {Boolean} now Pass `true` to force the refresh to happen now.
* @private
*/
refreshTip: function(now) {
var me = this,
later = !now && (me.isConfiguring || me.isReconfiguring),
data, text, tooltip;
if (!later) {
tooltip = me.getTip();
if (tooltip) {
data = me.getTooltipData();
text = tooltip(data);
me.setTooltipText(text);
}
}
me.invalidTip = later;
},
/**
* Convert the coordinates of the given `Event` into a rating value.
* @param {Ext.event.Event} event The event.
* @return {Number} The rating based on the given event coordinates.
* @private
*/
valueFromEvent: function(event) {
var me = this,
el = me.innerEl,
ex = event.getX(),
rounding = me.getRounding(),
cx = el.getX(),
x = ex - cx,
w = el.getWidth(),
limit = me.getLimit(),
v;
if (me.getInherited().rtl) {
x = w - x;
}
v = x / w * limit;
// We have to round up here so that the area we are over is considered
// the value.
v = Math.ceil(v / rounding) * rounding;
return v;
},
/**
* Convert the given rating into a width percentage.
* @param {Number} value The rating value to convert.
* @return {String} The width percentage to represent the given value.
* @private
*/
valueToPercent: function(value) {
value = (value / this.getLimit()) * 100;
return value + '%';
}
}
});