/** * Slider which supports vertical or horizontal orientation, keyboard adjustments, configurable snapping, axis clicking * and animation. Can be added as an item to any container. * * Sliders can be created with more than one thumb handle by passing an array of values instead of a single one: * * @example * 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 * constrainThumbs: false, * renderTo: Ext.getBody() * }); */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' ], /** * @cfg {Number} value * A value with which to initialize the slider. 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. * * Defaults to #minValue. */ /** * @cfg {Number[]} 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. */ vertical: false, /** * @cfg {Number} minValue * The minimum value for the Slider. */ minValue: 0, /** * @cfg {Number} maxValue * The maximum value for the Slider. */ maxValue: 100, /** * @cfg {Number/Boolean} decimalPrecision The number of decimal places to which to round the Slider's value. * * To disable rounding, configure as **false**. */ decimalPrecision: 0, /** * @cfg {Number} keyIncrement * How many units to change the Slider when adjusting with keyboard navigation. If the increment * config is larger, it will be used instead. */ keyIncrement: 1, /** * @cfg {Number} [pageSize=10] * How many units to change the Slider when using PageUp and PageDown keys. */ pageSize: 10, /** * @cfg {Number} increment * How many units to change the slider when adjusting by drag and drop. Use this option to enable 'snapping'. */ increment: 0, /** * @cfg {Boolean} [zeroBasedSnapping=false] * Set to `true` to calculate snap points based on {@link #increment}s from zero as opposed to * from this Slider's {@link #minValue}. * * By Default, valid snap points are calculated starting {@link #increment}s from the {@link #minValue} */ /** * @private * @property {Number[]} clickRange * 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. */ clickToChange : true, /** * @cfg {Object/Boolean} animate * Turn on or off animation. May be an animation configuration object: * * animate: { * duration: 3000, * easing: 'easeIn' * } */ animate: true, /** * @property {Boolean} dragging * True while the thumb is in a drag operation */ dragging: false, /** * @cfg {Boolean} constrainThumbs * True to disallow thumbs from overlapping one another. */ constrainThumbs: true, /** * @cfg {Object/Boolean} useTips * True to use an {@link Ext.slider.Tip} to display tips for the value. This option may also * provide a configuration object for an {@link Ext.slider.Tip}. */ useTips : true, /** * @cfg {Function/String} [tipText] * A function used to display custom text for the slider tip or the name of the * method on the corresponding `{@link Ext.app.ViewController controller}`. * * Defaults to null, which will use the default on the plugin. * * @cfg {Ext.slider.Thumb} tipText.thumb The Thumb that the Tip is attached to * @cfg {String} tipText.return The text to display in the tip * * @controllable */ tipText: null, /** * @inheritdoc */ defaultBindProperty: 'values', /** * @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. */ /** * @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 */ /** * @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 */ /** * @event dragstart * Fires after a drag operation has started. * @param {Ext.slider.Multi} slider The slider * @param {Ext.event.Event} e The event fired from Ext.dd.DragTracker */ /** * @event drag * Fires continuously during the drag operation while the mouse is moving. * @param {Ext.slider.Multi} slider The slider * @param {Ext.event.Event} e The event fired from Ext.dd.DragTracker */ /** * @event dragend * Fires after the drag operation has completed. * @param {Ext.slider.Multi} slider The slider * @param {Ext.event.Event} e The event fired from Ext.dd.DragTracker */ ariaRole: 'slider', focusable: true, needArrowKeys: true, tabIndex: 0, skipLabelForAttribute: true, focusCls: 'slider-focus', childEls: [ 'endEl', 'innerEl' ], // note: {id} here is really {inputId}, but {cmpId} is available fieldSubTpl: [ '<div id="{id}" data-ref="inputEl" {inputAttrTpl}', ' class="', Ext.baseCSSPrefix, 'slider {fieldCls} {vertical}', '{childElCls}"', '<tpl if="tabIdx != null"> tabindex="{tabIdx}"</tpl>', '<tpl foreach="ariaElAttributes"> {$}="{.}"</tpl>', '<tpl foreach="inputElAriaAttributes"> {$}="{.}"</tpl>', '>', '<div id="{cmpId}-endEl" data-ref="endEl" class="' + Ext.baseCSSPrefix + 'slider-end" role="presentation">', '<div id="{cmpId}-innerEl" data-ref="innerEl" class="' + Ext.baseCSSPrefix + 'slider-inner" role="presentation">', '{%this.renderThumbs(out, values)%}', '</div>', '</div>', '</div>', { renderThumbs: function(out, values) { var me = values.$comp, i = 0, thumbs = me.thumbs, len = thumbs.length, thumb, thumbConfig; for (; i < len; i++) { thumb = thumbs[i]; thumbConfig = thumb.getElConfig(); thumbConfig.id = me.id + '-thumb-' + i; Ext.DomHelper.generateMarkup(thumbConfig, out); } }, disableFormats: true } ], horizontalProp: 'left', initValue: function() { var me = this, extValueFrom = Ext.valueFrom, // Fallback for initial values: values config -> value config -> minValue config -> 0 values = extValueFrom(me.values, [extValueFrom(me.value, extValueFrom(me.minValue, 0))]), i = 0, len = values.length; // Store for use in dirty check me.originalValue = values; // Add a thumb for each value, enforcing configured constraints for (; i < len; i++) { me.addThumb(me.normalizeValue(values[i])); } }, initComponent: function() { var me = this, tipText = me.tipText, tipPlug, hasTip, p, pLen, plugins; /** * @property {Array} thumbs * Array containing references to each thumb */ me.thumbs = []; me.keyIncrement = Math.max(me.increment, me.keyIncrement); me.extraFieldBodyCls = Ext.baseCSSPrefix + 'slider-ct-' + (me.vertical ? 'vert' : 'horz'); me.callParent(); // only can use it if it exists. if (me.useTips) { tipPlug = {}; if (Ext.isObject(me.useTips)) { Ext.apply(tipPlug, me.useTips); } else if (tipText) { tipPlug.getText = tipText; } if (typeof(tipText = tipPlug.getText) === 'string') { tipPlug.getText = function (thumb) { return Ext.callback(tipText, null, [thumb], 0, me, me); }; } plugins = me.plugins = me.plugins || []; pLen = plugins.length; for (p = 0; p < pLen; p++) { if (plugins[p].isSliderTip) { hasTip = true; break; } } if (!hasTip) { me.plugins.push(new Ext.slider.Tip(tipPlug)); } } }, /** * Creates a new thumb and adds it to the slider * @param {Number} [value=0] The initial value to set on the thumb. * @return {Ext.slider.Thumb} The thumb */ addThumb: function(value) { var me = this, thumb = new Ext.slider.Thumb({ ownerCt : me, value : value, slider : me, index : me.thumbs.length, constrain : me.constrainThumbs, disabled : !!me.readOnly }); 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.thumbStack || (this.thumbStack = Ext.Array.slice(this.thumbs)), ln = thumbs.length, zIndex = 10000, i; // Move topthumb to position zero if (thumbs[0] !== topThumb) { Ext.Array.remove(thumbs, topThumb); thumbs.unshift(topThumb); } // Then shuffle the zIndices for (i = 0; i < ln; i++) { thumbs[i].el.setStyle('zIndex', zIndex); zIndex -= 1000; } }, getSubTplData : function(fieldData) { var me = this, data, ariaAttr; data = Ext.apply(me.callParent([fieldData]), { $comp: me, vertical: me.vertical ? Ext.baseCSSPrefix + 'slider-vert' : Ext.baseCSSPrefix + 'slider-horz', minValue: me.minValue, maxValue: me.maxValue, value: me.value, tabIdx: me.tabIndex, childElCls: '' }); ariaAttr = data.inputElAriaAttributes; if (ariaAttr) { if (!ariaAttr['aria-labelledby']) { ariaAttr['aria-labelledby'] = me.id + '-labelEl'; } ariaAttr['aria-orientation'] = me.vertical ? 'vertical' : 'horizontal'; ariaAttr['aria-valuemin'] = me.minValue; ariaAttr['aria-valuemax'] = me.maxValue; ariaAttr['aria-valuenow'] = me.value; } return data; }, onRender : function() { var me = this, thumbs = me.thumbs, len = thumbs.length, i = 0, thumb; me.callParent(arguments); for (i = 0; i < len; i++) { thumb = thumbs[i]; thumb.el = me.el.getById(me.id + '-thumb-' + i); thumb.onRender(); } }, /** * @private * Adds keyboard and mouse listeners on this.el. Ignores click events on the internal focus element. */ initEvents : function() { var me = this; me.callParent(); me.mon(me.el, { scope : me, mousedown: me.onMouseDown, keydown : me.onKeyDown }); }, onDragStart: Ext.emptyFn, onDragEnd: Ext.emptyFn, /** * @private * Given an `[x, y]` position within the slider's track (Points outside the slider's track are coerced to either the minimum or maximum value), * calculate how many pixels **from the slider origin** (left for horizontal Sliders and bottom for vertical Sliders) that point is. * * If the point is outside the range of the Slider's track, the return value is `undefined` * @param {Number[]} xy The point to calculate the track point for */ getTrackpoint : function(xy) { var me = this, vertical = me.vertical, sliderTrack = me.innerEl, trackLength, result, positionProperty; if (vertical) { positionProperty = 'top'; trackLength = sliderTrack.getHeight(); } else { positionProperty = me.horizontalProp; trackLength = sliderTrack.getWidth(); } xy = me.transformTrackPoints(sliderTrack.translatePoints(xy)); result = Ext.Number.constrain(xy[positionProperty], 0, trackLength); return vertical ? trackLength - result : result; }, transformTrackPoints: Ext.identityFn, // Base field checkChange method will fire 'change' event with signature common to all fields, // but Slider fires the same event with different signature. Hence we disable checkChange here // to avoid breakage. checkChange: Ext.emptyFn, /** * @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.event.Event} e The click event */ onMouseDown : function(e) { var me = this, thumbClicked = false, i = 0, thumbs = me.thumbs, len = thumbs.length, trackPoint; if (me.disabled) { return; } //see if the click was on any of the thumbs for (; !thumbClicked && i < len; i++) { thumbClicked = thumbClicked || e.target === thumbs[i].el.dom; } // Focus ourselves before setting the value. This allows other // fields that have blur handlers (for example, date/number field) // to take care of themselves first. This is important for // databinding. me.focus(); if (me.clickToChange && !thumbClicked) { trackPoint = me.getTrackpoint(e.getXY()); if (trackPoint !== undefined) { me.onClickChange(trackPoint); } } }, /** * @private * Moves the thumb to the indicated position. * Only changes the value if the click was within this.clickRange. * @param {Number} trackPoint local pixel offset **from the origin** (left for horizontal and bottom for vertical) along the Slider's axis at which the click event occured. */ onClickChange : function(trackPoint) { var me = this, thumb, index; // How far along the track *from the origin* was the click. // If vertical, the origin is the bottom of the slider track. //find the nearest thumb to the click event thumb = me.getNearest(trackPoint); if (!thumb.disabled) { index = thumb.index; me.setValue(index, Ext.util.Format.round(me.reversePixelValue(trackPoint), me.decimalPrecision), undefined, true); } }, /** * @private * Returns the nearest thumb to a click event, along with its distance * @param {Number} trackPoint local pixel position along the Slider's axis to find the Thumb for * @return {Object} The closest thumb object and its distance from the click event */ getNearest: function(trackPoint) { var me = this, clickValue = me.reversePixelValue(trackPoint), nearestDistance = me.getRange() + 5, //add a small fudge for the end of the slider 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) { // this makes sure that thumbs will stay in order if (nearest && nearest.value == value && value > clickValue && thumb.index > nearest.index) { continue; } nearest = thumb; 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.event.Event} e The Event object */ onKeyDown: function(e) { var me = this, ariaDom = me.ariaEl.dom, k, val; k = e.getKey(); /* * 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. */ if (me.disabled || me.thumbs.length !== 1) { // Must not mingle with the Tab key! if (k !== e.TAB) { e.preventDefault(); } return; } switch (k) { case e.UP: case e.RIGHT: val = e.ctrlKey ? me.maxValue : me.getValue(0) + me.keyIncrement; break; case e.DOWN: case e.LEFT: val = e.ctrlKey ? me.minValue : me.getValue(0) - me.keyIncrement; break; case e.HOME: val = me.minValue; break; case e.END: val = me.maxValue; break; case e.PAGE_UP: val = me.getValue(0) + me.pageSize; break; case e.PAGE_DOWN: val = me.getValue(0) - me.pageSize; break; } if (val !== undefined) { e.stopEvent(); val = me.normalizeValue(val); me.setValue(0, val, undefined, true); if (ariaDom) { ariaDom.setAttribute('aria-valuenow', val); } } }, /** * @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, snapFn = me.zeroBasedSnapping ? 'snap' : 'snapInRange'; v = Ext.Number[snapFn](v, me.increment, me.minValue, me.maxValue); 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, thumbs = me.thumbs, len = thumbs.length, ariaDom = me.ariaEl.dom, thumb, i; me.minValue = val; for (i = 0; i < len; ++i) { thumb = thumbs[i]; if (thumb.value < val) { me.setValue(i, val, false); } } if (ariaDom) { ariaDom.setAttribute('aria-valuemin', val); } 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, thumbs = me.thumbs, len = thumbs.length, ariaDom = me.ariaEl.dom, thumb, i; me.maxValue = val; for (i = 0; i < len; ++i) { thumb = thumbs[i]; if (thumb.value > val) { me.setValue(i, val, false); } } if (ariaDom) { ariaDom.setAttribute('aria-valuemax', val); } me.syncThumbs(); }, /** * Programmatically sets the value of the Slider. Ensures that the value is constrained within the minValue and * maxValue. * * Setting the second slider's value without animation: * * mySlider.setValue(1, 50, false); * * Setting multiple values with animation: * * mySlider.setValue([20, 40, 60], true); * * @param {Number/Number[]} index Index of the thumb to move. Alternatively, it can be an array of values to set * for each thumb in the slider. * @param {Number} value The value to set the slider to. (This will be constrained within minValue and maxValue) * @param {Object/Boolean} [animate] `false` to not animate. `true` to use the default animation. This may also be an * animate configuration object, see {@link #cfg-animate}. If this configuration is omitted, the {@link #cfg-animate} configuration * will be used. * @return {Ext.slider.Multi} this */ setValue : function(index, value, animate, changeComplete) { var me = this, thumbs = me.thumbs, ariaDom = me.ariaEl.dom, thumb, len, i, values; if (Ext.isArray(index)) { values = index; animate = value; for (i = 0, len = values.length; i < len; ++i) { thumb = thumbs[i]; if (thumb) { me.setValue(i, values[i], animate); } } return me; } 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) { if (Ext.isDefined(animate)) { animate = animate === false ? false : animate; } else { animate = me.animate; } thumb.move(me.calculateThumbPosition(value), animate); // At this moment we can only handle one thumb wrt ARIA if (index === 0 && ariaDom) { ariaDom.setAttribute('aria-valuenow', value); } me.fireEvent('change', me, value, thumb); me.checkDirty(); if (changeComplete) { me.fireEvent('changecomplete', me, value, thumb); } } } return me; }, /** * @private * Given a value within this Slider's range, calculates a Thumb's percentage CSS position to map that value. */ calculateThumbPosition : function(v) { var me = this, minValue = me.minValue, pos = (v - minValue) / me.getRange() * 100; if (isNaN(pos)) { pos = 0; } return pos; }, /** * @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 me = this, innerEl = me.innerEl, trackLength = me.vertical ? innerEl.getHeight() : innerEl.getWidth(), valueRange = me.getRange(); return valueRange === 0 ? trackLength : (trackLength / valueRange); }, getRange: function(){ return this.maxValue - this.minValue; }, /** * @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, reversePixelValue(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 */ reversePixelValue : function(pos) { return this.minValue + (pos / this.getRatio()); }, /** * @private * Given a Thumb's percentage position 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, reversePercentageValue(25) * returns 200 * @param {Number} pos The percentage along the slider track to return a mapped value for * @return {Number} The mapped value for the given position */ reversePercentageValue : function(pos) { return this.minValue + this.getRange() * (pos / 100); }, 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({ role: 'presentation', cls: Ext.baseCSSPrefix + 'slider-thumb ' + me.disabledCls }); } me.thumbHolder.show().setXY(xy); } } }, 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.calculateThumbPosition(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/Number[]} 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 {Number[]} 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, arr = [].concat(me.originalValue), a = 0, aLen = arr.length, val; for (; a < aLen; a++) { val = arr[a]; me.setValue(a, val); } me.clearInvalid(); // delete here so we reset back to the original state delete me.wasValid; }, setReadOnly: function(readOnly){ var me = this, thumbs = me.thumbs, len = thumbs.length, i = 0; me.callParent(arguments); readOnly = me.readOnly; for (; i < len; ++i) { if (readOnly) { thumbs[i].disable(); } else { thumbs[i].enable(); } } }, doDestroy: function() { if (this.rendered) { Ext.destroy(this.thumbs); } this.callParent(); }});