/** * 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 + '%'; } }});