X-Git-Url: http://git.ithinksw.org/extjs.git/blobdiff_plain/0494b8d9b9bb03ab6c22b34dae81261e3cd7e3e6..7a654f8d43fdb43d78b63d90528bed6e86b608cc:/src/slider/Multi.js diff --git a/src/slider/Multi.js b/src/slider/Multi.js new file mode 100644 index 00000000..8974c8a6 --- /dev/null +++ b/src/slider/Multi.js @@ -0,0 +1,828 @@ +/** + * @class Ext.slider.Multi + * @extends Ext.form.field.Base + *

Slider which supports vertical or horizontal orientation, keyboard adjustments, configurable snapping, axis + * clicking and animation. Can be added as an item to any container. In addition, + * {@img Ext.slider.Multi/Ext.slider.Multi.png Ext.slider.Multi component} + *

Example usage:

+ * Sliders can be created with more than one thumb handle by passing an array of values instead of a single one: +
+    Ext.create('Ext.slider.Multi', {
+        width: 200,
+        values: [25, 50, 75],
+        increment: 5,
+        minValue: 0,
+        maxValue: 100,
+
+        //this defaults to true, setting to false allows the thumbs to pass each other
+        {@link #constrainThumbs}: false,
+        renderTo: Ext.getBody()
+    });  
+
+ * @xtype multislider + */ +Ext.define('Ext.slider.Multi', { + extend: 'Ext.form.field.Base', + alias: 'widget.multislider', + alternateClassName: 'Ext.slider.MultiSlider', + + requires: [ + 'Ext.slider.Thumb', + 'Ext.slider.Tip', + 'Ext.Number', + 'Ext.util.Format', + 'Ext.Template', + 'Ext.layout.component.field.Slider' + ], + + fieldSubTpl: [ + '
', + '', + '
', + { + disableFormats: true, + compiled: true + } + ], + + /** + * @cfg {Number} value + * A value with which to initialize the slider. Defaults to minValue. Setting this will only + * result in the creation of a single slider thumb; if you want multiple thumbs then use the + * {@link #values} config instead. + */ + + /** + * @cfg {Array} values + * Array of Number values with which to initalize the slider. A separate slider thumb will be created for + * each value in this array. This will take precedence over the single {@link #value} config. + */ + + /** + * @cfg {Boolean} vertical Orient the Slider vertically rather than horizontally, defaults to false. + */ + vertical: false, + /** + * @cfg {Number} minValue The minimum value for the Slider. Defaults to 0. + */ + minValue: 0, + /** + * @cfg {Number} maxValue The maximum value for the Slider. Defaults to 100. + */ + maxValue: 100, + /** + * @cfg {Number/Boolean} decimalPrecision. + *

The number of decimal places to which to round the Slider's value. Defaults to 0.

+ *

To disable rounding, configure as false.

+ */ + decimalPrecision: 0, + /** + * @cfg {Number} keyIncrement How many units to change the Slider when adjusting with keyboard navigation. Defaults to 1. If the increment config is larger, it will be used instead. + */ + keyIncrement: 1, + /** + * @cfg {Number} increment How many units to change the slider when adjusting by drag and drop. Use this option to enable 'snapping'. + */ + increment: 0, + + /** + * @private + * @property clickRange + * @type Array + * Determines whether or not a click to the slider component is considered to be a user request to change the value. Specified as an array of [top, bottom], + * the click event's 'top' property is compared to these numbers and the click only considered a change request if it falls within them. e.g. if the 'top' + * value of the click event is 4 or 16, the click is not considered a change request as it falls outside of the [5, 15] range + */ + clickRange: [5,15], + + /** + * @cfg {Boolean} clickToChange Determines whether or not clicking on the Slider axis will change the slider. Defaults to true + */ + clickToChange : true, + /** + * @cfg {Boolean} animate Turn on or off animation. Defaults to true + */ + animate: true, + + /** + * True while the thumb is in a drag operation + * @type Boolean + */ + dragging: false, + + /** + * @cfg {Boolean} constrainThumbs True to disallow thumbs from overlapping one another. Defaults to true + */ + constrainThumbs: true, + + componentLayout: 'sliderfield', + + /** + * @cfg {Boolean} useTips + * True to use an Ext.slider.Tip to display tips for the value. Defaults to true. + */ + useTips : true, + + /** + * @cfg {Function} tipText + * A function used to display custom text for the slider tip. Defaults to null, which will + * use the default on the plugin. + */ + tipText : null, + + ariaRole: 'slider', + + // private override + initValue: function() { + var me = this, + extValue = Ext.value, + // Fallback for initial values: values config -> value config -> minValue config -> 0 + values = extValue(me.values, [extValue(me.value, extValue(me.minValue, 0))]), + i = 0, + len = values.length; + + // Store for use in dirty check + me.originalValue = values; + + // Add a thumb for each value + for (; i < len; i++) { + me.addThumb(values[i]); + } + }, + + // private override + initComponent : function() { + var me = this, + tipPlug, + hasTip; + + /** + * @property thumbs + * @type Array + * Array containing references to each thumb + */ + me.thumbs = []; + + me.keyIncrement = Math.max(me.increment, me.keyIncrement); + + me.addEvents( + /** + * @event beforechange + * Fires before the slider value is changed. By returning false from an event handler, + * you can cancel the event and prevent the slider from changing. + * @param {Ext.slider.Multi} slider The slider + * @param {Number} newValue The new value which the slider is being changed to. + * @param {Number} oldValue The old value which the slider was previously. + */ + 'beforechange', + + /** + * @event change + * Fires when the slider value is changed. + * @param {Ext.slider.Multi} slider The slider + * @param {Number} newValue The new value which the slider has been changed to. + * @param {Ext.slider.Thumb} thumb The thumb that was changed + */ + 'change', + + /** + * @event changecomplete + * Fires when the slider value is changed by the user and any drag operations have completed. + * @param {Ext.slider.Multi} slider The slider + * @param {Number} newValue The new value which the slider has been changed to. + * @param {Ext.slider.Thumb} thumb The thumb that was changed + */ + 'changecomplete', + + /** + * @event dragstart + * Fires after a drag operation has started. + * @param {Ext.slider.Multi} slider The slider + * @param {Ext.EventObject} e The event fired from Ext.dd.DragTracker + */ + 'dragstart', + + /** + * @event drag + * Fires continuously during the drag operation while the mouse is moving. + * @param {Ext.slider.Multi} slider The slider + * @param {Ext.EventObject} e The event fired from Ext.dd.DragTracker + */ + 'drag', + + /** + * @event dragend + * Fires after the drag operation has completed. + * @param {Ext.slider.Multi} slider The slider + * @param {Ext.EventObject} e The event fired from Ext.dd.DragTracker + */ + 'dragend' + ); + + if (me.vertical) { + Ext.apply(me, Ext.slider.Multi.Vertical); + } + + me.callParent(); + + // only can use it if it exists. + if (me.useTips) { + tipPlug = me.tipText ? {getText: me.tipText} : {}; + me.plugins = me.plugins || []; + Ext.each(me.plugins, function(plug){ + if (plug.isSliderTip) { + hasTip = true; + return false; + } + }); + if (!hasTip) { + me.plugins.push(Ext.create('Ext.slider.Tip', tipPlug)); + } + } + }, + + /** + * Creates a new thumb and adds it to the slider + * @param {Number} value The initial value to set on the thumb. Defaults to 0 + * @return {Ext.slider.Thumb} The thumb + */ + addThumb: function(value) { + var me = this, + thumb = Ext.create('Ext.slider.Thumb', { + value : value, + slider : me, + index : me.thumbs.length, + constrain: me.constrainThumbs + }); + me.thumbs.push(thumb); + + //render the thumb now if needed + if (me.rendered) { + thumb.render(); + } + + return thumb; + }, + + /** + * @private + * Moves the given thumb above all other by increasing its z-index. This is called when as drag + * any thumb, so that the thumb that was just dragged is always at the highest z-index. This is + * required when the thumbs are stacked on top of each other at one of the ends of the slider's + * range, which can result in the user not being able to move any of them. + * @param {Ext.slider.Thumb} topThumb The thumb to move to the top + */ + promoteThumb: function(topThumb) { + var thumbs = this.thumbs, + ln = thumbs.length, + zIndex, thumb, i; + + for (i = 0; i < ln; i++) { + thumb = thumbs[i]; + + if (thumb == topThumb) { + thumb.bringToFront(); + } else { + thumb.sendToBack(); + } + } + }, + + // private override + onRender : function() { + var me = this, + i = 0, + thumbs = me.thumbs, + len = thumbs.length, + thumb; + + Ext.applyIf(me.subTplData, { + vertical: me.vertical ? Ext.baseCSSPrefix + 'slider-vert' : Ext.baseCSSPrefix + 'slider-horz', + minValue: me.minValue, + maxValue: me.maxValue, + value: me.value + }); + + Ext.applyIf(me.renderSelectors, { + endEl: '.' + Ext.baseCSSPrefix + 'slider-end', + innerEl: '.' + Ext.baseCSSPrefix + 'slider-inner', + focusEl: '.' + Ext.baseCSSPrefix + 'slider-focus' + }); + + me.callParent(arguments); + + //render each thumb + for (; i < len; i++) { + thumbs[i].render(); + } + + //calculate the size of half a thumb + thumb = me.innerEl.down('.' + Ext.baseCSSPrefix + 'slider-thumb'); + me.halfThumb = (me.vertical ? thumb.getHeight() : thumb.getWidth()) / 2; + + }, + + /** + * Utility method to set the value of the field when the slider changes. + * @param {Object} slider The slider object. + * @param {Object} v The new value. + * @private + */ + onChange : function(slider, v) { + this.setValue(v, undefined, true); + }, + + /** + * @private + * Adds keyboard and mouse listeners on this.el. Ignores click events on the internal focus element. + */ + initEvents : function() { + var me = this; + + me.mon(me.el, { + scope : me, + mousedown: me.onMouseDown, + keydown : me.onKeyDown, + change : me.onChange + }); + + me.focusEl.swallowEvent("click", true); + }, + + /** + * @private + * Mousedown handler for the slider. If the clickToChange is enabled and the click was not on the draggable 'thumb', + * this calculates the new value of the slider and tells the implementation (Horizontal or Vertical) to move the thumb + * @param {Ext.EventObject} e The click event + */ + onMouseDown : function(e) { + var me = this, + thumbClicked = false, + i = 0, + thumbs = me.thumbs, + len = thumbs.length, + local; + + if (me.disabled) { + return; + } + + //see if the click was on any of the thumbs + for (; i < len; i++) { + thumbClicked = thumbClicked || e.target == thumbs[i].el.dom; + } + + if (me.clickToChange && !thumbClicked) { + local = me.innerEl.translatePoints(e.getXY()); + me.onClickChange(local); + } + me.focus(); + }, + + /** + * @private + * Moves the thumb to the indicated position. Note that a Vertical implementation is provided in Ext.slider.Multi.Vertical. + * Only changes the value if the click was within this.clickRange. + * @param {Object} local Object containing top and left values for the click event. + */ + onClickChange : function(local) { + var me = this, + thumb, index; + + if (local.top > me.clickRange[0] && local.top < me.clickRange[1]) { + //find the nearest thumb to the click event + thumb = me.getNearest(local, 'left'); + if (!thumb.disabled) { + index = thumb.index; + me.setValue(index, Ext.util.Format.round(me.reverseValue(local.left), me.decimalPrecision), undefined, true); + } + } + }, + + /** + * @private + * Returns the nearest thumb to a click event, along with its distance + * @param {Object} local Object containing top and left values from a click event + * @param {String} prop The property of local to compare on. Use 'left' for horizontal sliders, 'top' for vertical ones + * @return {Object} The closest thumb object and its distance from the click event + */ + getNearest: function(local, prop) { + var me = this, + localValue = prop == 'top' ? me.innerEl.getHeight() - local[prop] : local[prop], + clickValue = me.reverseValue(localValue), + nearestDistance = (me.maxValue - me.minValue) + 5, //add a small fudge for the end of the slider + index = 0, + nearest = null, + thumbs = me.thumbs, + i = 0, + len = thumbs.length, + thumb, + value, + dist; + + for (; i < len; i++) { + thumb = me.thumbs[i]; + value = thumb.value; + dist = Math.abs(value - clickValue); + + if (Math.abs(dist <= nearestDistance)) { + nearest = thumb; + index = i; + nearestDistance = dist; + } + } + return nearest; + }, + + /** + * @private + * Handler for any keypresses captured by the slider. If the key is UP or RIGHT, the thumb is moved along to the right + * by this.keyIncrement. If DOWN or LEFT it is moved left. Pressing CTRL moves the slider to the end in either direction + * @param {Ext.EventObject} e The Event object + */ + onKeyDown : function(e) { + /* + * The behaviour for keyboard handling with multiple thumbs is currently undefined. + * There's no real sane default for it, so leave it like this until we come up + * with a better way of doing it. + */ + var me = this, + k, + val; + + if(me.disabled || me.thumbs.length !== 1) { + e.preventDefault(); + return; + } + k = e.getKey(); + + switch(k) { + case e.UP: + case e.RIGHT: + e.stopEvent(); + val = e.ctrlKey ? me.maxValue : me.getValue(0) + me.keyIncrement; + me.setValue(0, val, undefined, true); + break; + case e.DOWN: + case e.LEFT: + e.stopEvent(); + val = e.ctrlKey ? me.minValue : me.getValue(0) - me.keyIncrement; + me.setValue(0, val, undefined, true); + break; + default: + e.preventDefault(); + } + }, + + /** + * @private + * If using snapping, this takes a desired new value and returns the closest snapped + * value to it + * @param {Number} value The unsnapped value + * @return {Number} The value of the nearest snap target + */ + doSnap : function(value) { + var newValue = value, + inc = this.increment, + m; + + if (!(inc && value)) { + return value; + } + m = value % inc; + if (m !== 0) { + newValue -= m; + if (m * 2 >= inc) { + newValue += inc; + } else if (m * 2 < -inc) { + newValue -= inc; + } + } + return Ext.Number.constrain(newValue, this.minValue, this.maxValue); + }, + + // private + afterRender : function() { + var me = this, + i = 0, + thumbs = me.thumbs, + len = thumbs.length, + thumb, + v; + + me.callParent(arguments); + + for (; i < len; i++) { + thumb = thumbs[i]; + + if (thumb.value !== undefined) { + v = me.normalizeValue(thumb.value); + if (v !== thumb.value) { + // delete this.value; + me.setValue(i, v, false); + } else { + thumb.move(me.translateValue(v), false); + } + } + } + }, + + /** + * @private + * Returns the ratio of pixels to mapped values. e.g. if the slider is 200px wide and maxValue - minValue is 100, + * the ratio is 2 + * @return {Number} The ratio of pixels to mapped values + */ + getRatio : function() { + var w = this.innerEl.getWidth(), + v = this.maxValue - this.minValue; + return v === 0 ? w : (w/v); + }, + + /** + * @private + * Returns a snapped, constrained value when given a desired value + * @param {Number} value Raw number value + * @return {Number} The raw value rounded to the correct d.p. and constrained within the set max and min values + */ + normalizeValue : function(v) { + var me = this; + + v = me.doSnap(v); + v = Ext.util.Format.round(v, me.decimalPrecision); + v = Ext.Number.constrain(v, me.minValue, me.maxValue); + return v; + }, + + /** + * Sets the minimum value for the slider instance. If the current value is less than the + * minimum value, the current value will be changed. + * @param {Number} val The new minimum value + */ + setMinValue : function(val) { + var me = this, + i = 0, + thumbs = me.thumbs, + len = thumbs.length, + t; + + me.minValue = val; + me.inputEl.dom.setAttribute('aria-valuemin', val); + + for (; i < len; ++i) { + t = thumbs[i]; + t.value = t.value < val ? val : t.value; + } + me.syncThumbs(); + }, + + /** + * Sets the maximum value for the slider instance. If the current value is more than the + * maximum value, the current value will be changed. + * @param {Number} val The new maximum value + */ + setMaxValue : function(val) { + var me = this, + i = 0, + thumbs = me.thumbs, + len = thumbs.length, + t; + + me.maxValue = val; + me.inputEl.dom.setAttribute('aria-valuemax', val); + + for (; i < len; ++i) { + t = thumbs[i]; + t.value = t.value > val ? val : t.value; + } + me.syncThumbs(); + }, + + /** + * Programmatically sets the value of the Slider. Ensures that the value is constrained within + * the minValue and maxValue. + * @param {Number} index Index of the thumb to move + * @param {Number} value The value to set the slider to. (This will be constrained within minValue and maxValue) + * @param {Boolean} animate Turn on or off animation, defaults to true + */ + setValue : function(index, value, animate, changeComplete) { + var me = this, + thumb = me.thumbs[index]; + + // ensures value is contstrained and snapped + value = me.normalizeValue(value); + + if (value !== thumb.value && me.fireEvent('beforechange', me, value, thumb.value, thumb) !== false) { + thumb.value = value; + if (me.rendered) { + // TODO this only handles a single value; need a solution for exposing multiple values to aria. + // Perhaps this should go on each thumb element rather than the outer element. + me.inputEl.set({ + 'aria-valuenow': value, + 'aria-valuetext': value + }); + + thumb.move(me.translateValue(value), Ext.isDefined(animate) ? animate !== false : me.animate); + + me.fireEvent('change', me, value, thumb); + if (changeComplete) { + me.fireEvent('changecomplete', me, value, thumb); + } + } + } + }, + + /** + * @private + */ + translateValue : function(v) { + var ratio = this.getRatio(); + return (v * ratio) - (this.minValue * ratio) - this.halfThumb; + }, + + /** + * @private + * Given a pixel location along the slider, returns the mapped slider value for that pixel. + * E.g. if we have a slider 200px wide with minValue = 100 and maxValue = 500, reverseValue(50) + * returns 200 + * @param {Number} pos The position along the slider to return a mapped value for + * @return {Number} The mapped value for the given position + */ + reverseValue : function(pos) { + var ratio = this.getRatio(); + return (pos + (this.minValue * ratio)) / ratio; + }, + + // private + focus : function() { + this.focusEl.focus(10); + }, + + //private + onDisable: function() { + var me = this, + i = 0, + thumbs = me.thumbs, + len = thumbs.length, + thumb, + el, + xy; + + me.callParent(); + + for (; i < len; i++) { + thumb = thumbs[i]; + el = thumb.el; + + thumb.disable(); + + if(Ext.isIE) { + //IE breaks when using overflow visible and opacity other than 1. + //Create a place holder for the thumb and display it. + xy = el.getXY(); + el.hide(); + + me.innerEl.addCls(me.disabledCls).dom.disabled = true; + + if (!me.thumbHolder) { + me.thumbHolder = me.endEl.createChild({cls: Ext.baseCSSPrefix + 'slider-thumb ' + me.disabledCls}); + } + + me.thumbHolder.show().setXY(xy); + } + } + }, + + //private + onEnable: function() { + var me = this, + i = 0, + thumbs = me.thumbs, + len = thumbs.length, + thumb, + el; + + this.callParent(); + + for (; i < len; i++) { + thumb = thumbs[i]; + el = thumb.el; + + thumb.enable(); + + if (Ext.isIE) { + me.innerEl.removeCls(me.disabledCls).dom.disabled = false; + + if (me.thumbHolder) { + me.thumbHolder.hide(); + } + + el.show(); + me.syncThumbs(); + } + } + }, + + /** + * Synchronizes thumbs position to the proper proportion of the total component width based + * on the current slider {@link #value}. This will be called automatically when the Slider + * is resized by a layout, but if it is rendered auto width, this method can be called from + * another resize handler to sync the Slider if necessary. + */ + syncThumbs : function() { + if (this.rendered) { + var thumbs = this.thumbs, + length = thumbs.length, + i = 0; + + for (; i < length; i++) { + thumbs[i].move(this.translateValue(thumbs[i].value)); + } + } + }, + + /** + * Returns the current value of the slider + * @param {Number} index The index of the thumb to return a value for + * @return {Number/Array} The current value of the slider at the given index, or an array of + * all thumb values if no index is given. + */ + getValue : function(index) { + return Ext.isNumber(index) ? this.thumbs[index].value : this.getValues(); + }, + + /** + * Returns an array of values - one for the location of each thumb + * @return {Array} The set of thumb values + */ + getValues: function() { + var values = [], + i = 0, + thumbs = this.thumbs, + len = thumbs.length; + + for (; i < len; i++) { + values.push(thumbs[i].value); + } + + return values; + }, + + getSubmitValue: function() { + var me = this; + return (me.disabled || !me.submitValue) ? null : me.getValue(); + }, + + reset: function() { + var me = this, + Array = Ext.Array; + Array.forEach(Array.from(me.originalValue), function(val, i) { + me.setValue(i, val); + }); + me.clearInvalid(); + // delete here so we reset back to the original state + delete me.wasValid; + }, + + // private + beforeDestroy : function() { + var me = this; + + Ext.destroyMembers(me.innerEl, me.endEl, me.focusEl); + Ext.each(me.thumbs, function(thumb) { + Ext.destroy(thumb); + }, me); + + me.callParent(); + }, + + statics: { + // Method overrides to support slider with vertical orientation + Vertical: { + getRatio : function() { + var h = this.innerEl.getHeight(), + v = this.maxValue - this.minValue; + return h/v; + }, + + onClickChange : function(local) { + var me = this, + thumb, index, bottom; + + if (local.left > me.clickRange[0] && local.left < me.clickRange[1]) { + thumb = me.getNearest(local, 'top'); + if (!thumb.disabled) { + index = thumb.index; + bottom = me.reverseValue(me.innerEl.getHeight() - local.top); + + me.setValue(index, Ext.util.Format.round(me.minValue + bottom, me.decimalPrecision), undefined, true); + } + } + } + } + } +});