UNPKG

@qooxdoo/framework

Version:

The JS Framework for Coders

1,075 lines (914 loc) 30.1 kB
/* ************************************************************************ qooxdoo - the new era of web development http://qooxdoo.org Copyright: 2004-2008 1&1 Internet AG, Germany, http://www.1und1.de License: MIT: https://opensource.org/licenses/MIT See the LICENSE file in the project's top-level directory for details. Authors: * Sebastian Werner (wpbasti) * Fabian Jakobs (fjakobs) ************************************************************************ */ /** * The Slider widget provides a vertical or horizontal slider. * * The Slider is the classic widget for controlling a bounded value. * It lets the user move a slider handle along a horizontal or vertical * groove and translates the handle's position into an integer value * within the defined range. * * The Slider has very few of its own functions. * The most useful functions are slideTo() to set the slider directly to some * value; setSingleStep(), setPageStep() to set the steps; and setMinimum() * and setMaximum() to define the range of the slider. * * A slider accepts focus on Tab and provides both a mouse wheel and * a keyboard interface. The keyboard interface is the following: * * * Left/Right move a horizontal slider by one single step. * * Up/Down move a vertical slider by one single step. * * PageUp moves up one page. * * PageDown moves down one page. * * Home moves to the start (minimum). * * End moves to the end (maximum). * * Here are the main properties of the class: * * # <code>value</code>: The bounded integer that {@link qx.ui.form.INumberForm} * maintains. * # <code>minimum</code>: The lowest possible value. * # <code>maximum</code>: The highest possible value. * # <code>singleStep</code>: The smaller of two natural steps that an abstract * sliders provides and typically corresponds to the user pressing an arrow key. * # <code>pageStep</code>: The larger of two natural steps that an abstract * slider provides and typically corresponds to the user pressing PageUp or * PageDown. * * @childControl knob {qx.ui.core.Widget} knob to set the value of the slider */ qx.Class.define("qx.ui.form.Slider", { extend: qx.ui.core.Widget, implement: [qx.ui.form.IForm, qx.ui.form.INumberForm, qx.ui.form.IRange], include: [qx.ui.form.MForm], /* ***************************************************************************** CONSTRUCTOR ***************************************************************************** */ /** * @param orientation {String?"horizontal"} Configure the * {@link #orientation} property */ construct(orientation) { super(); // Force canvas layout this._setLayout(new qx.ui.layout.Canvas()); // ARIA attrs this.getContentElement().setAttribute("role", "slider"); // Add listeners this.addListener("keypress", this._onKeyPress, this); this.addListener("roll", this._onRoll, this); this.addListener("pointerdown", this._onPointerDown, this); this.addListener("pointerup", this._onPointerUp, this); this.addListener("losecapture", this._onPointerUp, this); this.addListener("resize", this._onUpdate, this); // Stop events this.addListener("contextmenu", this._onStopEvent, this); this.addListener("tap", this._onStopEvent, this); this.addListener("dbltap", this._onStopEvent, this); // Initialize orientation if (orientation != null) { this.setOrientation(orientation); } else { this.initOrientation(); } }, /* ***************************************************************************** EVENTS ***************************************************************************** */ events: { /** * Change event for the value. */ changeValue: "qx.event.type.Data", /** Fired as soon as the slide animation ended. */ slideAnimationEnd: "qx.event.type.Event" }, /* ***************************************************************************** PROPERTIES ***************************************************************************** */ properties: { // overridden appearance: { refine: true, init: "slider" }, // overridden focusable: { refine: true, init: true }, /** Whether the slider is horizontal or vertical. */ orientation: { check: ["horizontal", "vertical"], init: "horizontal", apply: "_applyOrientation" }, /** * The current slider value. * * Strictly validates according to {@link #minimum} and {@link #maximum}. * Do not apply any value correction to the incoming value. If you depend * on this, please use {@link #slideTo} instead. */ value: { check: "typeof value==='number'&&value>=this.getMinimum()&&value<=this.getMaximum()", init: 0, apply: "_applyValue", nullable: true }, /** * The minimum slider value (may be negative). This value must be smaller * than {@link #maximum}. */ minimum: { check: "Integer", init: 0, apply: "_applyMinimum", event: "changeMinimum" }, /** * The maximum slider value (may be negative). This value must be larger * than {@link #minimum}. */ maximum: { check: "Integer", init: 100, apply: "_applyMaximum", event: "changeMaximum" }, /** * The amount to increment on each event. Typically corresponds * to the user pressing an arrow key. */ singleStep: { check: "Integer", init: 1 }, /** * The amount to increment on each event. Typically corresponds * to the user pressing <code>PageUp</code> or <code>PageDown</code>. */ pageStep: { check: "Integer", init: 10 }, /** * Factor to apply to the width/height of the knob in relation * to the dimension of the underlying area. */ knobFactor: { check: "Number", apply: "_applyKnobFactor", nullable: true } }, /* ***************************************************************************** MEMBERS ***************************************************************************** */ members: { __sliderLocation: null, __knobLocation: null, __knobSize: null, __dragMode: null, __dragOffset: null, __trackingMode: null, __trackingDirection: null, __trackingEnd: null, __timer: null, // event delay stuff during drag __dragTimer: null, __lastValueEvent: null, __dragValue: null, __scrollAnimationframe: null, // overridden /** * @lint ignoreReferenceField(_forwardStates) */ _forwardStates: { invalid: true }, // overridden renderLayout(left, top, width, height) { super.renderLayout(left, top, width, height); // make sure the layout engine does not override the knob position this._updateKnobPosition(); }, // overridden _createChildControlImpl(id, hash) { var control; switch (id) { case "knob": control = new qx.ui.core.Widget(); control.addListener("resize", this._onUpdate, this); control.addListener("pointerover", this._onPointerOver, this); control.addListener("pointerout", this._onPointerOut, this); this._add(control); break; } return control || super._createChildControlImpl(id); }, /* --------------------------------------------------------------------------- EVENT HANDLER --------------------------------------------------------------------------- */ /** * Event handler for pointerover events at the knob child control. * * Adds the 'hovered' state * * @param e {qx.event.type.Pointer} Incoming pointer event */ _onPointerOver(e) { this.addState("hovered"); }, /** * Event handler for pointerout events at the knob child control. * * Removes the 'hovered' state * * @param e {qx.event.type.Pointer} Incoming pointer event */ _onPointerOut(e) { this.removeState("hovered"); }, /** * Listener of roll event * * @param e {qx.event.type.Roll} Incoming event object */ _onRoll(e) { // only wheel if (e.getPointerType() != "wheel") { return; } var axis = this.getOrientation() === "horizontal" ? "x" : "y"; var delta = e.getDelta()[axis]; var direction = delta > 0 ? 1 : delta < 0 ? -1 : 0; this.slideBy(direction * this.getSingleStep()); e.stop(); }, /** * Event handler for keypress events. * * Adds support for arrow keys, page up, page down, home and end keys. * * @param e {qx.event.type.KeySequence} Incoming keypress event */ _onKeyPress(e) { var isHorizontal = this.getOrientation() === "horizontal"; var backward = isHorizontal ? "Left" : "Up"; var forward = isHorizontal ? "Right" : "Down"; switch (e.getKeyIdentifier()) { case forward: this.slideForward(); break; case backward: this.slideBack(); break; case "PageDown": this.slidePageForward(100); break; case "PageUp": this.slidePageBack(100); break; case "Home": this.slideToBegin(200); break; case "End": this.slideToEnd(200); break; default: return; } // Stop processed events e.stop(); }, /** * Listener of pointerdown event. Initializes drag or tracking mode. * * @param e {qx.event.type.Pointer} Incoming event object */ _onPointerDown(e) { // this can happen if the user releases the button while dragging outside // of the browser viewport if (this.__dragMode) { return; } var isHorizontal = this.__isHorizontal; var knob = this.getChildControl("knob"); var locationProperty = isHorizontal ? "left" : "top"; var cursorLocation = isHorizontal ? e.getDocumentLeft() : e.getDocumentTop(); var decorator = this.getDecorator(); decorator = qx.theme.manager.Decoration.getInstance().resolve(decorator); if (isHorizontal) { var decoratorPadding = decorator ? decorator.getInsets().left : 0; var padding = (this.getPaddingLeft() || 0) + decoratorPadding; } else { var decoratorPadding = decorator ? decorator.getInsets().top : 0; var padding = (this.getPaddingTop() || 0) + decoratorPadding; } var sliderLocation = (this.__sliderLocation = qx.bom.element.Location.get( this.getContentElement().getDomElement() )[locationProperty]); sliderLocation += padding; var knobLocation = (this.__knobLocation = qx.bom.element.Location.get( knob.getContentElement().getDomElement() )[locationProperty]); if (e.getTarget() === knob) { // Switch into drag mode this.__dragMode = true; if (!this.__dragTimer) { // create a timer to fire delayed dragging events if dragging stops. this.__dragTimer = new qx.event.Timer(100); this.__dragTimer.addListener("interval", this._fireValue, this); } this.__dragTimer.start(); // Compute dragOffset (includes both: inner position of the widget and // cursor position on knob) this.__dragOffset = cursorLocation + sliderLocation - knobLocation; // add state knob.addState("pressed"); } else { // Switch into tracking mode this.__trackingMode = true; // Detect tracking direction this.__trackingDirection = cursorLocation <= knobLocation ? -1 : 1; // Compute end value this.__computeTrackingEnd(e); // Directly call interval method once this._onInterval(); // Initialize timer (when needed) if (!this.__timer) { this.__timer = new qx.event.Timer(100); this.__timer.addListener("interval", this._onInterval, this); } // Start timer this.__timer.start(); } // Register move listener this.addListener("pointermove", this._onPointerMove, this); // Activate capturing this.capture(); // Stop event e.stopPropagation(); }, /** * Listener of pointerup event. Used for cleanup of previously * initialized modes. * * @param e {qx.event.type.Pointer} Incoming event object */ _onPointerUp(e) { if (this.__dragMode) { // Release capture mode this.releaseCapture(); // Cleanup status flags delete this.__dragMode; // as we come out of drag mode, make // sure content gets synced this.__dragTimer.stop(); this._fireValue(); delete this.__dragOffset; // remove state this.getChildControl("knob").removeState("pressed"); // it's necessary to check whether the cursor is over the knob widget to be able to // to decide whether to remove the 'hovered' state. if (e.getType() === "pointerup") { var deltaSlider; var deltaPosition; var positionSlider; if (this.__isHorizontal) { deltaSlider = e.getDocumentLeft() - (this._valueToPosition(this.getValue()) + this.__sliderLocation); positionSlider = qx.bom.element.Location.get( this.getContentElement().getDomElement() )["top"]; deltaPosition = e.getDocumentTop() - (positionSlider + this.getChildControl("knob").getBounds().top); } else { deltaSlider = e.getDocumentTop() - (this._valueToPosition(this.getValue()) + this.__sliderLocation); positionSlider = qx.bom.element.Location.get( this.getContentElement().getDomElement() )["left"]; deltaPosition = e.getDocumentLeft() - (positionSlider + this.getChildControl("knob").getBounds().left); } if ( deltaPosition < 0 || deltaPosition > this.__knobSize || deltaSlider < 0 || deltaSlider > this.__knobSize ) { this.getChildControl("knob").removeState("hovered"); } } } else if (this.__trackingMode) { // Stop timer interval this.__timer.stop(); // Release capture mode this.releaseCapture(); // Cleanup status flags delete this.__trackingMode; delete this.__trackingDirection; delete this.__trackingEnd; } // Remove move listener again this.removeListener("pointermove", this._onPointerMove, this); // Stop event if (e.getType() === "pointerup") { e.stopPropagation(); } }, /** * Listener of pointermove event for the knob. Only used in drag mode. * * @param e {qx.event.type.Pointer} Incoming event object */ _onPointerMove(e) { if (this.__dragMode) { var dragStop = this.__isHorizontal ? e.getDocumentLeft() : e.getDocumentTop(); var position = dragStop - this.__dragOffset; this.slideTo(this._positionToValue(position)); } else if (this.__trackingMode) { // Update tracking end on pointermove this.__computeTrackingEnd(e); } // Stop event e.stopPropagation(); }, /** * Listener of interval event by the internal timer. Only used * in tracking sequences. * * @param e {qx.event.type.Event} Incoming event object */ _onInterval(e) { // Compute new value var value = this.getValue() + this.__trackingDirection * this.getPageStep(); // Limit value if (value < this.getMinimum()) { value = this.getMinimum(); } else if (value > this.getMaximum()) { value = this.getMaximum(); } // Stop at tracking position (where the pointer is pressed down) var slideBack = this.__trackingDirection == -1; if ( (slideBack && value <= this.__trackingEnd) || (!slideBack && value >= this.__trackingEnd) ) { value = this.__trackingEnd; } // Finally slide to the desired position this.slideTo(value); }, /** * Listener of resize event for both the slider itself and the knob. * * @param e {qx.event.type.Data} Incoming event object */ _onUpdate(e) { // Update sliding space var availSize = this.getInnerSize(); var knobSize = this.getChildControl("knob").getBounds(); var sizeProperty = this.__isHorizontal ? "width" : "height"; // Sync knob size this._updateKnobSize(); // Store knob size this.__slidingSpace = availSize[sizeProperty] - knobSize[sizeProperty]; this.__knobSize = knobSize[sizeProperty]; // Update knob position (sliding space must be updated first) this._updateKnobPosition(); }, /* --------------------------------------------------------------------------- UTILS --------------------------------------------------------------------------- */ /** @type {Boolean} Whether the slider is laid out horizontally */ __isHorizontal: false, /** * @type {Integer} Available space for knob to slide on, computed on resize of * the widget */ __slidingSpace: 0, /** * Computes the value where the tracking should end depending on * the current pointer position. * * @param e {qx.event.type.Pointer} Incoming pointer event */ __computeTrackingEnd(e) { var isHorizontal = this.__isHorizontal; var cursorLocation = isHorizontal ? e.getDocumentLeft() : e.getDocumentTop(); var sliderLocation = this.__sliderLocation; var knobLocation = this.__knobLocation; var knobSize = this.__knobSize; // Compute relative position var position = cursorLocation - sliderLocation; if (cursorLocation >= knobLocation) { position -= knobSize; } // Compute stop value var value = this._positionToValue(position); var min = this.getMinimum(); var max = this.getMaximum(); if (value < min) { value = min; } else if (value > max) { value = max; } else { var old = this.getValue(); var step = this.getPageStep(); var method = this.__trackingDirection < 0 ? "floor" : "ceil"; // Fix to page step value = old + Math[method]((value - old) / step) * step; } // Store value when undefined, otherwise only when it follows the // current direction e.g. goes up or down if ( this.__trackingEnd == null || (this.__trackingDirection == -1 && value <= this.__trackingEnd) || (this.__trackingDirection == 1 && value >= this.__trackingEnd) ) { this.__trackingEnd = value; } }, /** * Converts the given position to a value. * * Does not respect single or page step. * * @param position {Integer} Position to use * @return {Integer} Resulting value (rounded) */ _positionToValue(position) { // Reading available space var avail = this.__slidingSpace; // Protect undefined value (before initial resize) and division by zero if (avail == null || avail == 0) { return 0; } // Compute and limit percent var percent = position / avail; if (percent < 0) { percent = 0; } else if (percent > 1) { percent = 1; } // Compute range var range = this.getMaximum() - this.getMinimum(); // Compute value return this.getMinimum() + Math.round(range * percent); }, /** * Converts the given value to a position to place * the knob to. * * @param value {Integer} Value to use * @return {Integer} Computed position (rounded) */ _valueToPosition(value) { // Reading available space var avail = this.__slidingSpace; if (avail == null) { return 0; } // Computing range var range = this.getMaximum() - this.getMinimum(); // Protect division by zero if (range == 0) { return 0; } // Translating value to distance from minimum var value = value - this.getMinimum(); // Compute and limit percent var percent = value / range; if (percent < 0) { percent = 0; } else if (percent > 1) { percent = 1; } // Compute position from available space and percent return Math.round(avail * percent); }, /** * Updates the knob position following the currently configured * value. Useful on reflows where the dimensions of the slider * itself have been modified. * */ _updateKnobPosition() { this._setKnobPosition(this._valueToPosition(this.getValue())); }, /** * Moves the knob to the given position. * * @param position {Integer} Any valid position (needs to be * greater or equal than zero) */ _setKnobPosition(position) { // Use the DOM Element to prevent unnecessary layout recalculations var knob = this.getChildControl("knob"); var dec = this.getDecorator(); dec = qx.theme.manager.Decoration.getInstance().resolve(dec); var content = knob.getContentElement(); if (this.__isHorizontal) { if (dec && dec.getPadding()) { position += dec.getPadding().left; } position += this.getPaddingLeft() || 0; content.setStyle("left", position + "px", true); } else { if (dec && dec.getPadding()) { position += dec.getPadding().top; } position += this.getPaddingTop() || 0; content.setStyle("top", position + "px", true); } }, /** * Reconfigures the size of the knob depending on * the optionally defined {@link #knobFactor}. * */ _updateKnobSize() { // Compute knob size var knobFactor = this.getKnobFactor(); if (knobFactor == null) { return; } // Ignore when not rendered yet var avail = this.getInnerSize(); if (avail == null) { return; } // Read size property if (this.__isHorizontal) { this.getChildControl("knob").setWidth( Math.round(knobFactor * avail.width) ); } else { this.getChildControl("knob").setHeight( Math.round(knobFactor * avail.height) ); } }, /* --------------------------------------------------------------------------- SLIDE METHODS --------------------------------------------------------------------------- */ /** * Slides backward to the minimum value * @param duration {Number} The time in milliseconds the slide to should take. */ slideToBegin(duration) { this.slideTo(this.getMinimum(), duration); }, /** * Slides forward to the maximum value * @param duration {Number} The time in milliseconds the slide to should take. */ slideToEnd(duration) { this.slideTo(this.getMaximum(), duration); }, /** * Slides forward (right or bottom depending on orientation) * */ slideForward() { this.slideBy(this.getSingleStep()); }, /** * Slides backward (to left or top depending on orientation) * */ slideBack() { this.slideBy(-this.getSingleStep()); }, /** * Slides a page forward (to right or bottom depending on orientation) * @param duration {Number} The time in milliseconds the slide to should take. */ slidePageForward(duration) { this.slideBy(this.getPageStep(), duration); }, /** * Slides a page backward (to left or top depending on orientation) * @param duration {Number} The time in milliseconds the slide to should take. */ slidePageBack(duration) { this.slideBy(-this.getPageStep(), duration); }, /** * Slides by the given offset. * * This method works with the value, not with the coordinate. * * @param offset {Integer} Offset to scroll by * @param duration {Number} The time in milliseconds the slide to should take. */ slideBy(offset, duration) { this.slideTo(this.getValue() + offset, duration); }, /** * Slides to the given value * * This method works with the value, not with the coordinate. * * @param value {Integer} Scroll to a value between the defined * minimum and maximum. * @param duration {Number} The time in milliseconds the slide to should take. */ slideTo(value, duration) { this.stopSlideAnimation(); if (duration) { this.__animateTo(value, duration); } else { this.updatePosition(value); } }, /** * Updates the position property considering the minimum and maximum values. * @param value {Number} The new position. */ updatePosition(value) { this.setValue(this.__normalizeValue(value)); }, /** * In case a slide animation is currently running, it will be stopped. * If not, the method does nothing. */ stopSlideAnimation() { if (this.__scrollAnimationframe) { this.__scrollAnimationframe.cancelSequence(); this.__scrollAnimationframe = null; } }, /** * Internal helper to normalize the given value concerning the minimum * and maximum value. * @param value {Number} The value to normalize. * @return {Number} The normalized value. */ __normalizeValue(value) { // Bring into allowed range or fix to single step grid if (value < this.getMinimum()) { value = this.getMinimum(); } else if (value > this.getMaximum()) { value = this.getMaximum(); } else { value = this.getMinimum() + Math.round((value - this.getMinimum()) / this.getSingleStep()) * this.getSingleStep(); } return value; }, /** * Animation helper which takes care of the animated slide. * @param to {Number} The target value. * @param duration {Number} The time in milliseconds the slide to should take. */ __animateTo(to, duration) { to = this.__normalizeValue(to); var from = this.getValue(); this.__scrollAnimationframe = new qx.bom.AnimationFrame(); this.__scrollAnimationframe.on( "frame", function (timePassed) { this.setValue(parseInt((timePassed / duration) * (to - from) + from)); }, this ); this.__scrollAnimationframe.on( "end", function () { this.setValue(to); this.__scrollAnimationframe = null; this.fireEvent("slideAnimationEnd"); }, this ); this.__scrollAnimationframe.startSequence(duration); }, /* --------------------------------------------------------------------------- PROPERTY APPLY ROUTINES --------------------------------------------------------------------------- */ // property apply _applyOrientation(value, old) { // ARIA attrs this.getContentElement().setAttribute("aria-orientation", value); var knob = this.getChildControl("knob"); // Update private flag for faster access this.__isHorizontal = value === "horizontal"; // Toggle states and knob layout if (this.__isHorizontal) { this.removeState("vertical"); knob.removeState("vertical"); this.addState("horizontal"); knob.addState("horizontal"); knob.setLayoutProperties({ top: 0, right: null, bottom: 0 }); } else { this.removeState("horizontal"); knob.removeState("horizontal"); this.addState("vertical"); knob.addState("vertical"); knob.setLayoutProperties({ right: 0, bottom: null, left: 0 }); } // Sync knob position this._updateKnobPosition(); }, // property apply _applyKnobFactor(value, old) { if (value != null) { this._updateKnobSize(); } else { if (this.__isHorizontal) { this.getChildControl("knob").resetWidth(); } else { this.getChildControl("knob").resetHeight(); } } }, // property apply _applyValue(value, old) { if (value != null) { // ARIA attrs this.getContentElement().setAttribute("aria-valuenow", value); this._updateKnobPosition(); if (this.__dragMode) { this.__dragValue = [value, old]; } else { this.fireEvent("changeValue", qx.event.type.Data, [value, old]); } } else { this.resetValue(); } }, /** * Helper for applyValue which fires the changeValue event. */ _fireValue() { if (!this.__dragValue) { return; } var tmp = this.__dragValue; this.__dragValue = null; this.fireEvent("changeValue", qx.event.type.Data, tmp); }, // property apply _applyMinimum(value, old) { // ARIA attrs this.getContentElement().setAttribute("aria-valuemin", value); if (this.getValue() < value) { this.setValue(value); } this._updateKnobPosition(); }, // property apply _applyMaximum(value, old) { // ARIA attrs this.getContentElement().setAttribute("aria-valuemax", value); if (this.getValue() > value) { this.setValue(value); } this._updateKnobPosition(); } } });