aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMichał Gołębiowski-Owczarek <m.goleb@gmail.com>2025-03-21 00:03:17 +0100
committerGitHub <noreply@github.com>2025-03-21 00:03:17 +0100
commit44de3d325c1ac0c4a841deff0ec03265a0b670f7 (patch)
tree1e2d6714843aead988ec134ecace8a75e01cba24
parent6843ced12e4051aefbee47cf87fa79794737eb8a (diff)
downloadjquery-ui-44de3d325c1ac0c4a841deff0ec03265a0b670f7.tar.gz
jquery-ui-44de3d325c1ac0c4a841deff0ec03265a0b670f7.zip
Spinner: Prevent double mousewheel & wheel event handlingHEADmain
As of gh-2338, if one has loaded the jQuery MouseWheel plugin, the `mousewheel` handler would fire the `wheel` one, but the `wheel` one would also run in response to the native `wheel` event, resulting in double the distance handled by the spinner. To prevent the issue, only fire the `wheel` handler from inside the `mousewheel` on if the event was triggered by jQuery - jQuery will not care that the underlying event is `wheel` and will only fire handlers for `mousewheel`. Also, add an iframe test using jQuery MouseWheel to not affect all the other tests. Plus, migrate from `QUnit.reset` to `QUnit.done` (see qunitjs/qunit#354). Closes gh-2342 Ref gh-2338
-rw-r--r--Gruntfile.js3
-rw-r--r--bower.json1
-rw-r--r--external/jquery-mousewheel/LICENSE.txt36
-rw-r--r--external/jquery-mousewheel/jquery.mousewheel.js242
-rw-r--r--tests/lib/helper.js59
-rw-r--r--tests/lib/qunit.js23
-rw-r--r--tests/lib/testIframe.js7
-rw-r--r--tests/unit/spinner/core.js14
-rw-r--r--tests/unit/spinner/mousewheel-wheel.html72
-rw-r--r--ui/widgets/spinner.js7
10 files changed, 456 insertions, 8 deletions
diff --git a/Gruntfile.js b/Gruntfile.js
index 4f7dcc73e..bbb71d33e 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -247,6 +247,9 @@ grunt.initConfig( {
"requirejs/require.js": "requirejs/require.js",
+ "jquery-mousewheel/jquery.mousewheel.js": "jquery-mousewheel/jquery.mousewheel.js",
+ "jquery-mousewheel/LICENSE.txt": "jquery-mousewheel/LICENSE.txt",
+
"jquery-simulate/jquery.simulate.js": "jquery-simulate/jquery.simulate.js",
"jquery-simulate/LICENSE.txt": "jquery-simulate/LICENSE.txt",
diff --git a/bower.json b/bower.json
index 3ed76cee9..eb3187e0c 100644
--- a/bower.json
+++ b/bower.json
@@ -13,6 +13,7 @@
},
"devDependencies": {
"jquery-color": "3.0.0",
+ "jquery-mousewheel": "3.2.2",
"jquery-simulate": "1.1.1",
"qunit": "2.19.4",
"requirejs": "2.1.14",
diff --git a/external/jquery-mousewheel/LICENSE.txt b/external/jquery-mousewheel/LICENSE.txt
new file mode 100644
index 000000000..f56b79ae0
--- /dev/null
+++ b/external/jquery-mousewheel/LICENSE.txt
@@ -0,0 +1,36 @@
+Copyright OpenJS Foundation and other contributors, https://openjsf.org/
+
+This software consists of voluntary contributions made by many
+individuals. For exact contribution history, see the revision history
+available at https://github.com/jquery/jquery-mousewheel
+
+The following license applies to all parts of this software except as
+documented below:
+
+====
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+====
+
+All files located in the node_modules and external directories are
+externally maintained libraries used by this software which have their
+own licenses; we recommend you read them, as their terms may differ from
+the terms above.
diff --git a/external/jquery-mousewheel/jquery.mousewheel.js b/external/jquery-mousewheel/jquery.mousewheel.js
new file mode 100644
index 000000000..aec55baf8
--- /dev/null
+++ b/external/jquery-mousewheel/jquery.mousewheel.js
@@ -0,0 +1,242 @@
+/*!
+ * jQuery Mousewheel 3.2.2
+ * Copyright OpenJS Foundation and other contributors
+ */
+
+( function( factory ) {
+ "use strict";
+
+ if ( typeof define === "function" && define.amd ) {
+
+ // AMD. Register as an anonymous module.
+ define( [ "jquery" ], factory );
+ } else if ( typeof exports === "object" ) {
+
+ // Node/CommonJS style for Browserify
+ module.exports = factory;
+ } else {
+
+ // Browser globals
+ factory( jQuery );
+ }
+} )( function( $ ) {
+ "use strict";
+
+ var nullLowestDeltaTimeout, lowestDelta,
+ modernEvents = !!$.fn.on,
+ toFix = [ "wheel", "mousewheel", "DOMMouseScroll", "MozMousePixelScroll" ],
+ toBind = ( "onwheel" in window.document || window.document.documentMode >= 9 ) ?
+ [ "wheel" ] : [ "mousewheel", "DomMouseScroll", "MozMousePixelScroll" ],
+ slice = Array.prototype.slice;
+
+ if ( $.event.fixHooks ) {
+ for ( var i = toFix.length; i; ) {
+ $.event.fixHooks[ toFix[ --i ] ] = $.event.mouseHooks;
+ }
+ }
+
+ var special = $.event.special.mousewheel = {
+ version: "3.2.2",
+
+ setup: function() {
+ if ( this.addEventListener ) {
+ for ( var i = toBind.length; i; ) {
+ this.addEventListener( toBind[ --i ], handler, false );
+ }
+ } else {
+ this.onmousewheel = handler;
+ }
+
+ // Store the line height and page height for this particular element
+ $.data( this, "mousewheel-line-height", special.getLineHeight( this ) );
+ $.data( this, "mousewheel-page-height", special.getPageHeight( this ) );
+ },
+
+ teardown: function() {
+ if ( this.removeEventListener ) {
+ for ( var i = toBind.length; i; ) {
+ this.removeEventListener( toBind[ --i ], handler, false );
+ }
+ } else {
+ this.onmousewheel = null;
+ }
+
+ // Clean up the data we added to the element
+ $.removeData( this, "mousewheel-line-height" );
+ $.removeData( this, "mousewheel-page-height" );
+ },
+
+ getLineHeight: function( elem ) {
+ var $elem = $( elem ),
+ $parent = $elem[ "offsetParent" in $.fn ? "offsetParent" : "parent" ]();
+ if ( !$parent.length ) {
+ $parent = $( "body" );
+ }
+ return parseInt( $parent.css( "fontSize" ), 10 ) ||
+ parseInt( $elem.css( "fontSize" ), 10 ) || 16;
+ },
+
+ getPageHeight: function( elem ) {
+ return $( elem ).height();
+ },
+
+ settings: {
+ adjustOldDeltas: true, // see shouldAdjustOldDeltas() below
+ normalizeOffset: true // calls getBoundingClientRect for each event
+ }
+ };
+
+ $.fn.extend( {
+ mousewheel: function( fn ) {
+ return fn ?
+ this[ modernEvents ? "on" : "bind" ]( "mousewheel", fn ) :
+ this.trigger( "mousewheel" );
+ },
+
+ unmousewheel: function( fn ) {
+ return this[ modernEvents ? "off" : "unbind" ]( "mousewheel", fn );
+ }
+ } );
+
+
+ function handler( event ) {
+ var orgEvent = event || window.event,
+ args = slice.call( arguments, 1 ),
+ delta = 0,
+ deltaX = 0,
+ deltaY = 0,
+ absDelta = 0;
+ event = $.event.fix( orgEvent );
+ event.type = "mousewheel";
+
+ // Old school scrollwheel delta
+ if ( "detail" in orgEvent ) {
+ deltaY = orgEvent.detail * -1;
+ }
+ if ( "wheelDelta" in orgEvent ) {
+ deltaY = orgEvent.wheelDelta;
+ }
+ if ( "wheelDeltaY" in orgEvent ) {
+ deltaY = orgEvent.wheelDeltaY;
+ }
+ if ( "wheelDeltaX" in orgEvent ) {
+ deltaX = orgEvent.wheelDeltaX * -1;
+ }
+
+ // Firefox < 17 horizontal scrolling related to DOMMouseScroll event
+ if ( "axis" in orgEvent && orgEvent.axis === orgEvent.HORIZONTAL_AXIS ) {
+ deltaX = deltaY * -1;
+ deltaY = 0;
+ }
+
+ // Set delta to be deltaY or deltaX if deltaY is 0 for backwards compatability
+ delta = deltaY === 0 ? deltaX : deltaY;
+
+ // New school wheel delta (wheel event)
+ if ( "deltaY" in orgEvent ) {
+ deltaY = orgEvent.deltaY * -1;
+ delta = deltaY;
+ }
+ if ( "deltaX" in orgEvent ) {
+ deltaX = orgEvent.deltaX;
+ if ( deltaY === 0 ) {
+ delta = deltaX * -1;
+ }
+ }
+
+ // No change actually happened, no reason to go any further
+ if ( deltaY === 0 && deltaX === 0 ) {
+ return;
+ }
+
+ // Need to convert lines and pages to pixels if we aren't already in pixels
+ // There are three delta modes:
+ // * deltaMode 0 is by pixels, nothing to do
+ // * deltaMode 1 is by lines
+ // * deltaMode 2 is by pages
+ if ( orgEvent.deltaMode === 1 ) {
+ var lineHeight = $.data( this, "mousewheel-line-height" );
+ delta *= lineHeight;
+ deltaY *= lineHeight;
+ deltaX *= lineHeight;
+ } else if ( orgEvent.deltaMode === 2 ) {
+ var pageHeight = $.data( this, "mousewheel-page-height" );
+ delta *= pageHeight;
+ deltaY *= pageHeight;
+ deltaX *= pageHeight;
+ }
+
+ // Store lowest absolute delta to normalize the delta values
+ absDelta = Math.max( Math.abs( deltaY ), Math.abs( deltaX ) );
+
+ if ( !lowestDelta || absDelta < lowestDelta ) {
+ lowestDelta = absDelta;
+
+ // Adjust older deltas if necessary
+ if ( shouldAdjustOldDeltas( orgEvent, absDelta ) ) {
+ lowestDelta /= 40;
+ }
+ }
+
+ // Adjust older deltas if necessary
+ if ( shouldAdjustOldDeltas( orgEvent, absDelta ) ) {
+
+ // Divide all the things by 40!
+ delta /= 40;
+ deltaX /= 40;
+ deltaY /= 40;
+ }
+
+ // Get a whole, normalized value for the deltas
+ delta = Math[ delta >= 1 ? "floor" : "ceil" ]( delta / lowestDelta );
+ deltaX = Math[ deltaX >= 1 ? "floor" : "ceil" ]( deltaX / lowestDelta );
+ deltaY = Math[ deltaY >= 1 ? "floor" : "ceil" ]( deltaY / lowestDelta );
+
+ // Normalise offsetX and offsetY properties
+ if ( special.settings.normalizeOffset && this.getBoundingClientRect ) {
+ var boundingRect = this.getBoundingClientRect();
+ event.offsetX = event.clientX - boundingRect.left;
+ event.offsetY = event.clientY - boundingRect.top;
+ }
+
+ // Add information to the event object
+ event.deltaX = deltaX;
+ event.deltaY = deltaY;
+ event.deltaFactor = lowestDelta;
+
+ // Go ahead and set deltaMode to 0 since we converted to pixels
+ // Although this is a little odd since we overwrite the deltaX/Y
+ // properties with normalized deltas.
+ event.deltaMode = 0;
+
+ // Add event and delta to the front of the arguments
+ args.unshift( event, delta, deltaX, deltaY );
+
+ // Clear out lowestDelta after sometime to better
+ // handle multiple device types that give different
+ // a different lowestDelta
+ // Ex: trackpad = 3 and mouse wheel = 120
+ if ( nullLowestDeltaTimeout ) {
+ window.clearTimeout( nullLowestDeltaTimeout );
+ }
+ nullLowestDeltaTimeout = window.setTimeout( function() {
+ lowestDelta = null;
+ }, 200 );
+
+ return ( $.event.dispatch || $.event.handle ).apply( this, args );
+ }
+
+ function shouldAdjustOldDeltas( orgEvent, absDelta ) {
+
+ // If this is an older event and the delta is divisible by 120,
+ // then we are assuming that the browser is treating this as an
+ // older mouse wheel event and that we should divide the deltas
+ // by 40 to try and get a more usable deltaFactor.
+ // Side note, this actually impacts the reported scroll distance
+ // in older browsers and can cause scrolling to be slower than native.
+ // Turn this off by setting $.event.special.mousewheel.settings.adjustOldDeltas to false.
+ return special.settings.adjustOldDeltas && orgEvent.type === "mousewheel" &&
+ absDelta % 120 === 0;
+ }
+
+} );
diff --git a/tests/lib/helper.js b/tests/lib/helper.js
index 2315c5e19..2be4c48de 100644
--- a/tests/lib/helper.js
+++ b/tests/lib/helper.js
@@ -51,6 +51,65 @@ exports.moduleAfterEach = function( assert ) {
}
};
+exports.testIframe = function( title, fileName, func, wrapper, iframeStyles ) {
+ if ( !wrapper ) {
+ wrapper = QUnit.test;
+ }
+ wrapper.call( QUnit, title, function( assert ) {
+ var done = assert.async(),
+ $iframe = jQuery( "<iframe></iframe>" )
+ .css( {
+ position: "absolute",
+ top: "0",
+ left: "-600px",
+ width: "500px",
+ zIndex: 1,
+ background: "white"
+ } )
+ .attr( { id: "qunit-fixture-iframe", src: fileName } );
+
+ // Add other iframe styles
+ if ( iframeStyles ) {
+ $iframe.css( iframeStyles );
+ }
+
+ // Test iframes are expected to invoke this via startIframeTest
+ // (cf. iframeTest.js)
+ window.iframeCallback = function() {
+ var args = Array.prototype.slice.call( arguments );
+
+ args.unshift( assert );
+
+ setTimeout( function() {
+ var result;
+
+ this.iframeCallback = undefined;
+
+ result = func.apply( this, args );
+
+ function finish() {
+ func = function() {};
+ $iframe.remove();
+ done();
+ }
+
+ // Wait for promises returned by `func`.
+ if ( result && result.then ) {
+ result.then( finish );
+ } else {
+ finish();
+ }
+ } );
+ };
+
+ // Attach iframe to the body for visibility-dependent code.
+ // It will be removed by either the above code, or the testDone
+ // callback in qunit.js.
+ $iframe.prependTo( document.body );
+ } );
+};
+window.iframeCallback = undefined;
+
return exports;
} );
diff --git a/tests/lib/qunit.js b/tests/lib/qunit.js
index 6441019bd..c4c96ef58 100644
--- a/tests/lib/qunit.js
+++ b/tests/lib/qunit.js
@@ -7,6 +7,8 @@ define( [
], function( QUnit, $ ) {
"use strict";
+var ajaxSettings = $.ajaxSettings;
+
QUnit.config.autostart = false;
QUnit.config.requireExpects = true;
@@ -34,16 +36,21 @@ QUnit.config.urlConfig.push( {
label: "Enable jquery-migrate"
} );
-QUnit.reset = ( function( reset ) {
- return function() {
+QUnit.testDone( function() {
+
+ // Ensure jQuery events and data on the fixture are properly removed
+ $( "#qunit-fixture" ).empty();
- // Ensure jQuery events and data on the fixture are properly removed
- $( "#qunit-fixture" ).empty();
+ // Remove the iframe fixture
+ $( "#qunit-fixture-iframe" ).remove();
- // Let QUnit reset the fixture
- reset.apply( this, arguments );
- };
-} )( QUnit.reset );
+ // Reset internal $ state
+ if ( ajaxSettings ) {
+ $.ajaxSettings = $.extend( true, {}, ajaxSettings );
+ } else {
+ delete $.ajaxSettings;
+ }
+} );
return QUnit;
diff --git a/tests/lib/testIframe.js b/tests/lib/testIframe.js
new file mode 100644
index 000000000..4db56833c
--- /dev/null
+++ b/tests/lib/testIframe.js
@@ -0,0 +1,7 @@
+window.startIframeTest = function() {
+ var args = Array.prototype.slice.call( arguments );
+
+ // Note: jQuery may be undefined if page did not load it
+ args.unshift( window.jQuery, window, document );
+ window.parent.iframeCallback.apply( null, args );
+};
diff --git a/tests/unit/spinner/core.js b/tests/unit/spinner/core.js
index befe439f6..42bcc7bb5 100644
--- a/tests/unit/spinner/core.js
+++ b/tests/unit/spinner/core.js
@@ -239,6 +239,20 @@ QUnit.test( "mousewheel on input (DEPRECATED)", function( assert ) {
}
} );
+helper.testIframe(
+ "wheel & mousewheel conflicts",
+ "mousewheel-wheel.html",
+ function( assert, jQuery, window, document, values ) {
+ assert.expect( 5 );
+
+ assert.equal( values[ 0 ], 0, "wheel event without delta does not change value" );
+ assert.equal( values[ 1 ], 2, "delta -1" );
+ assert.equal( values[ 2 ], 0, "delta 0.2" );
+ assert.equal( values[ 3 ], -2, "delta 15" );
+ assert.equal( values[ 4 ], -2, "wheel when not focused" );
+ }
+);
+
QUnit.test( "reading HTML5 attributes", function( assert ) {
assert.expect( 6 );
var markup = "<input type='number' min='-100' max='100' value='5' step='2'>",
diff --git a/tests/unit/spinner/mousewheel-wheel.html b/tests/unit/spinner/mousewheel-wheel.html
new file mode 100644
index 000000000..e512a36cc
--- /dev/null
+++ b/tests/unit/spinner/mousewheel-wheel.html
@@ -0,0 +1,72 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title>jQuery UI Spinner Test Suite</title>
+
+ <script src="../../../external/requirejs/require.js"></script>
+ <script src="../../../external/jquery/jquery.js"></script>
+ <script src="../../lib/css.js" data-modules="core button spinner theme"></script>
+ <script src="../../lib/testIframe.js"></script>
+</head>
+<body>
+
+<input id="spin" class="foo">
+
+<script>
+ function runTest() {
+ var values = [],
+ element = $( "#spin" ).val( 0 ).spinner( {
+ step: 2
+ } );
+
+ element.focus();
+ setTimeout( step1 );
+
+ function dispatchWheelEvent( elem, deltaY ) {
+ elem[ 0 ].dispatchEvent( new WheelEvent( "wheel", {
+ deltaY: deltaY
+ } ) );
+ }
+
+ function step1() {
+ dispatchWheelEvent( element );
+ values.push( element.val() );
+
+ dispatchWheelEvent( element, -1 );
+ values.push( element.val() );
+
+ dispatchWheelEvent( element, 0.2 );
+ values.push( element.val() );
+
+ dispatchWheelEvent( element, 15 );
+ values.push( element.val() );
+
+ element.blur();
+ setTimeout( step2 );
+ }
+
+ function step2() {
+ dispatchWheelEvent( element, -1 );
+ values.push( element.val() );
+
+ startIframeTest( values );
+ }
+ }
+
+ requirejs.config( {
+ paths: {
+ "jquery-mousewheel": "../../../external/jquery-mousewheel/jquery.mousewheel",
+ "ui": "../../../ui"
+ },
+ } );
+
+ require( [
+ "jquery-mousewheel",
+ "ui/widgets/spinner"
+ ], function() {
+ runTest();
+ } );
+</script>
+</body>
+</html>
diff --git a/ui/widgets/spinner.js b/ui/widgets/spinner.js
index 4fb41d7bb..d4034b458 100644
--- a/ui/widgets/spinner.js
+++ b/ui/widgets/spinner.js
@@ -164,6 +164,13 @@ $.widget( "ui.spinner", {
// event. The `delta` parameter is provided by the jQuery Mousewheel
// plugin if one is loaded.
mousewheel: function( event, delta ) {
+ if ( !event.isTrigger ) {
+
+ // If this is not a trigger call, the `wheel` handler will
+ // fire as well, let's not duplicate it.
+ return;
+ }
+
var wheelEvent = $.Event( event );
wheelEvent.type = "wheel";
if ( delta ) {