Skip to content

Commit 44de3d3

Browse files
authoredMar 20, 2025··
Spinner: Prevent double mousewheel & wheel event handling
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
1 parent 6843ced commit 44de3d3

File tree

10 files changed

+456
-8
lines changed

10 files changed

+456
-8
lines changed
 

‎Gruntfile.js

+3
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,9 @@ grunt.initConfig( {
247247

248248
"requirejs/require.js": "requirejs/require.js",
249249

250+
"jquery-mousewheel/jquery.mousewheel.js": "jquery-mousewheel/jquery.mousewheel.js",
251+
"jquery-mousewheel/LICENSE.txt": "jquery-mousewheel/LICENSE.txt",
252+
250253
"jquery-simulate/jquery.simulate.js": "jquery-simulate/jquery.simulate.js",
251254
"jquery-simulate/LICENSE.txt": "jquery-simulate/LICENSE.txt",
252255

‎bower.json

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
},
1414
"devDependencies": {
1515
"jquery-color": "3.0.0",
16+
"jquery-mousewheel": "3.2.2",
1617
"jquery-simulate": "1.1.1",
1718
"qunit": "2.19.4",
1819
"requirejs": "2.1.14",
+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
Copyright OpenJS Foundation and other contributors, https://openjsf.org/
2+
3+
This software consists of voluntary contributions made by many
4+
individuals. For exact contribution history, see the revision history
5+
available at https://github.com/jquery/jquery-mousewheel
6+
7+
The following license applies to all parts of this software except as
8+
documented below:
9+
10+
====
11+
12+
Permission is hereby granted, free of charge, to any person obtaining
13+
a copy of this software and associated documentation files (the
14+
"Software"), to deal in the Software without restriction, including
15+
without limitation the rights to use, copy, modify, merge, publish,
16+
distribute, sublicense, and/or sell copies of the Software, and to
17+
permit persons to whom the Software is furnished to do so, subject to
18+
the following conditions:
19+
20+
The above copyright notice and this permission notice shall be
21+
included in all copies or substantial portions of the Software.
22+
23+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
24+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
25+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
26+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
27+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
28+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
29+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
30+
31+
====
32+
33+
All files located in the node_modules and external directories are
34+
externally maintained libraries used by this software which have their
35+
own licenses; we recommend you read them, as their terms may differ from
36+
the terms above.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
/*!
2+
* jQuery Mousewheel 3.2.2
3+
* Copyright OpenJS Foundation and other contributors
4+
*/
5+
6+
( function( factory ) {
7+
"use strict";
8+
9+
if ( typeof define === "function" && define.amd ) {
10+
11+
// AMD. Register as an anonymous module.
12+
define( [ "jquery" ], factory );
13+
} else if ( typeof exports === "object" ) {
14+
15+
// Node/CommonJS style for Browserify
16+
module.exports = factory;
17+
} else {
18+
19+
// Browser globals
20+
factory( jQuery );
21+
}
22+
} )( function( $ ) {
23+
"use strict";
24+
25+
var nullLowestDeltaTimeout, lowestDelta,
26+
modernEvents = !!$.fn.on,
27+
toFix = [ "wheel", "mousewheel", "DOMMouseScroll", "MozMousePixelScroll" ],
28+
toBind = ( "onwheel" in window.document || window.document.documentMode >= 9 ) ?
29+
[ "wheel" ] : [ "mousewheel", "DomMouseScroll", "MozMousePixelScroll" ],
30+
slice = Array.prototype.slice;
31+
32+
if ( $.event.fixHooks ) {
33+
for ( var i = toFix.length; i; ) {
34+
$.event.fixHooks[ toFix[ --i ] ] = $.event.mouseHooks;
35+
}
36+
}
37+
38+
var special = $.event.special.mousewheel = {
39+
version: "3.2.2",
40+
41+
setup: function() {
42+
if ( this.addEventListener ) {
43+
for ( var i = toBind.length; i; ) {
44+
this.addEventListener( toBind[ --i ], handler, false );
45+
}
46+
} else {
47+
this.onmousewheel = handler;
48+
}
49+
50+
// Store the line height and page height for this particular element
51+
$.data( this, "mousewheel-line-height", special.getLineHeight( this ) );
52+
$.data( this, "mousewheel-page-height", special.getPageHeight( this ) );
53+
},
54+
55+
teardown: function() {
56+
if ( this.removeEventListener ) {
57+
for ( var i = toBind.length; i; ) {
58+
this.removeEventListener( toBind[ --i ], handler, false );
59+
}
60+
} else {
61+
this.onmousewheel = null;
62+
}
63+
64+
// Clean up the data we added to the element
65+
$.removeData( this, "mousewheel-line-height" );
66+
$.removeData( this, "mousewheel-page-height" );
67+
},
68+
69+
getLineHeight: function( elem ) {
70+
var $elem = $( elem ),
71+
$parent = $elem[ "offsetParent" in $.fn ? "offsetParent" : "parent" ]();
72+
if ( !$parent.length ) {
73+
$parent = $( "body" );
74+
}
75+
return parseInt( $parent.css( "fontSize" ), 10 ) ||
76+
parseInt( $elem.css( "fontSize" ), 10 ) || 16;
77+
},
78+
79+
getPageHeight: function( elem ) {
80+
return $( elem ).height();
81+
},
82+
83+
settings: {
84+
adjustOldDeltas: true, // see shouldAdjustOldDeltas() below
85+
normalizeOffset: true // calls getBoundingClientRect for each event
86+
}
87+
};
88+
89+
$.fn.extend( {
90+
mousewheel: function( fn ) {
91+
return fn ?
92+
this[ modernEvents ? "on" : "bind" ]( "mousewheel", fn ) :
93+
this.trigger( "mousewheel" );
94+
},
95+
96+
unmousewheel: function( fn ) {
97+
return this[ modernEvents ? "off" : "unbind" ]( "mousewheel", fn );
98+
}
99+
} );
100+
101+
102+
function handler( event ) {
103+
var orgEvent = event || window.event,
104+
args = slice.call( arguments, 1 ),
105+
delta = 0,
106+
deltaX = 0,
107+
deltaY = 0,
108+
absDelta = 0;
109+
event = $.event.fix( orgEvent );
110+
event.type = "mousewheel";
111+
112+
// Old school scrollwheel delta
113+
if ( "detail" in orgEvent ) {
114+
deltaY = orgEvent.detail * -1;
115+
}
116+
if ( "wheelDelta" in orgEvent ) {
117+
deltaY = orgEvent.wheelDelta;
118+
}
119+
if ( "wheelDeltaY" in orgEvent ) {
120+
deltaY = orgEvent.wheelDeltaY;
121+
}
122+
if ( "wheelDeltaX" in orgEvent ) {
123+
deltaX = orgEvent.wheelDeltaX * -1;
124+
}
125+
126+
// Firefox < 17 horizontal scrolling related to DOMMouseScroll event
127+
if ( "axis" in orgEvent && orgEvent.axis === orgEvent.HORIZONTAL_AXIS ) {
128+
deltaX = deltaY * -1;
129+
deltaY = 0;
130+
}
131+
132+
// Set delta to be deltaY or deltaX if deltaY is 0 for backwards compatability
133+
delta = deltaY === 0 ? deltaX : deltaY;
134+
135+
// New school wheel delta (wheel event)
136+
if ( "deltaY" in orgEvent ) {
137+
deltaY = orgEvent.deltaY * -1;
138+
delta = deltaY;
139+
}
140+
if ( "deltaX" in orgEvent ) {
141+
deltaX = orgEvent.deltaX;
142+
if ( deltaY === 0 ) {
143+
delta = deltaX * -1;
144+
}
145+
}
146+
147+
// No change actually happened, no reason to go any further
148+
if ( deltaY === 0 && deltaX === 0 ) {
149+
return;
150+
}
151+
152+
// Need to convert lines and pages to pixels if we aren't already in pixels
153+
// There are three delta modes:
154+
// * deltaMode 0 is by pixels, nothing to do
155+
// * deltaMode 1 is by lines
156+
// * deltaMode 2 is by pages
157+
if ( orgEvent.deltaMode === 1 ) {
158+
var lineHeight = $.data( this, "mousewheel-line-height" );
159+
delta *= lineHeight;
160+
deltaY *= lineHeight;
161+
deltaX *= lineHeight;
162+
} else if ( orgEvent.deltaMode === 2 ) {
163+
var pageHeight = $.data( this, "mousewheel-page-height" );
164+
delta *= pageHeight;
165+
deltaY *= pageHeight;
166+
deltaX *= pageHeight;
167+
}
168+
169+
// Store lowest absolute delta to normalize the delta values
170+
absDelta = Math.max( Math.abs( deltaY ), Math.abs( deltaX ) );
171+
172+
if ( !lowestDelta || absDelta < lowestDelta ) {
173+
lowestDelta = absDelta;
174+
175+
// Adjust older deltas if necessary
176+
if ( shouldAdjustOldDeltas( orgEvent, absDelta ) ) {
177+
lowestDelta /= 40;
178+
}
179+
}
180+
181+
// Adjust older deltas if necessary
182+
if ( shouldAdjustOldDeltas( orgEvent, absDelta ) ) {
183+
184+
// Divide all the things by 40!
185+
delta /= 40;
186+
deltaX /= 40;
187+
deltaY /= 40;
188+
}
189+
190+
// Get a whole, normalized value for the deltas
191+
delta = Math[ delta >= 1 ? "floor" : "ceil" ]( delta / lowestDelta );
192+
deltaX = Math[ deltaX >= 1 ? "floor" : "ceil" ]( deltaX / lowestDelta );
193+
deltaY = Math[ deltaY >= 1 ? "floor" : "ceil" ]( deltaY / lowestDelta );
194+
195+
// Normalise offsetX and offsetY properties
196+
if ( special.settings.normalizeOffset && this.getBoundingClientRect ) {
197+
var boundingRect = this.getBoundingClientRect();
198+
event.offsetX = event.clientX - boundingRect.left;
199+
event.offsetY = event.clientY - boundingRect.top;
200+
}
201+
202+
// Add information to the event object
203+
event.deltaX = deltaX;
204+
event.deltaY = deltaY;
205+
event.deltaFactor = lowestDelta;
206+
207+
// Go ahead and set deltaMode to 0 since we converted to pixels
208+
// Although this is a little odd since we overwrite the deltaX/Y
209+
// properties with normalized deltas.
210+
event.deltaMode = 0;
211+
212+
// Add event and delta to the front of the arguments
213+
args.unshift( event, delta, deltaX, deltaY );
214+
215+
// Clear out lowestDelta after sometime to better
216+
// handle multiple device types that give different
217+
// a different lowestDelta
218+
// Ex: trackpad = 3 and mouse wheel = 120
219+
if ( nullLowestDeltaTimeout ) {
220+
window.clearTimeout( nullLowestDeltaTimeout );
221+
}
222+
nullLowestDeltaTimeout = window.setTimeout( function() {
223+
lowestDelta = null;
224+
}, 200 );
225+
226+
return ( $.event.dispatch || $.event.handle ).apply( this, args );
227+
}
228+
229+
function shouldAdjustOldDeltas( orgEvent, absDelta ) {
230+
231+
// If this is an older event and the delta is divisible by 120,
232+
// then we are assuming that the browser is treating this as an
233+
// older mouse wheel event and that we should divide the deltas
234+
// by 40 to try and get a more usable deltaFactor.
235+
// Side note, this actually impacts the reported scroll distance
236+
// in older browsers and can cause scrolling to be slower than native.
237+
// Turn this off by setting $.event.special.mousewheel.settings.adjustOldDeltas to false.
238+
return special.settings.adjustOldDeltas && orgEvent.type === "mousewheel" &&
239+
absDelta % 120 === 0;
240+
}
241+
242+
} );

‎tests/lib/helper.js

+59
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,65 @@ exports.moduleAfterEach = function( assert ) {
5151
}
5252
};
5353

54+
exports.testIframe = function( title, fileName, func, wrapper, iframeStyles ) {
55+
if ( !wrapper ) {
56+
wrapper = QUnit.test;
57+
}
58+
wrapper.call( QUnit, title, function( assert ) {
59+
var done = assert.async(),
60+
$iframe = jQuery( "<iframe></iframe>" )
61+
.css( {
62+
position: "absolute",
63+
top: "0",
64+
left: "-600px",
65+
width: "500px",
66+
zIndex: 1,
67+
background: "white"
68+
} )
69+
.attr( { id: "qunit-fixture-iframe", src: fileName } );
70+
71+
// Add other iframe styles
72+
if ( iframeStyles ) {
73+
$iframe.css( iframeStyles );
74+
}
75+
76+
// Test iframes are expected to invoke this via startIframeTest
77+
// (cf. iframeTest.js)
78+
window.iframeCallback = function() {
79+
var args = Array.prototype.slice.call( arguments );
80+
81+
args.unshift( assert );
82+
83+
setTimeout( function() {
84+
var result;
85+
86+
this.iframeCallback = undefined;
87+
88+
result = func.apply( this, args );
89+
90+
function finish() {
91+
func = function() {};
92+
$iframe.remove();
93+
done();
94+
}
95+
96+
// Wait for promises returned by `func`.
97+
if ( result && result.then ) {
98+
result.then( finish );
99+
} else {
100+
finish();
101+
}
102+
} );
103+
};
104+
105+
// Attach iframe to the body for visibility-dependent code.
106+
// It will be removed by either the above code, or the testDone
107+
// callback in qunit.js.
108+
$iframe.prependTo( document.body );
109+
} );
110+
};
111+
window.iframeCallback = undefined;
112+
54113
return exports;
55114

56115
} );

‎tests/lib/qunit.js

+15-8
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ define( [
77
], function( QUnit, $ ) {
88
"use strict";
99

10+
var ajaxSettings = $.ajaxSettings;
11+
1012
QUnit.config.autostart = false;
1113
QUnit.config.requireExpects = true;
1214

@@ -34,16 +36,21 @@ QUnit.config.urlConfig.push( {
3436
label: "Enable jquery-migrate"
3537
} );
3638

37-
QUnit.reset = ( function( reset ) {
38-
return function() {
39+
QUnit.testDone( function() {
40+
41+
// Ensure jQuery events and data on the fixture are properly removed
42+
$( "#qunit-fixture" ).empty();
3943

40-
// Ensure jQuery events and data on the fixture are properly removed
41-
$( "#qunit-fixture" ).empty();
44+
// Remove the iframe fixture
45+
$( "#qunit-fixture-iframe" ).remove();
4246

43-
// Let QUnit reset the fixture
44-
reset.apply( this, arguments );
45-
};
46-
} )( QUnit.reset );
47+
// Reset internal $ state
48+
if ( ajaxSettings ) {
49+
$.ajaxSettings = $.extend( true, {}, ajaxSettings );
50+
} else {
51+
delete $.ajaxSettings;
52+
}
53+
} );
4754

4855
return QUnit;
4956

‎tests/lib/testIframe.js

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
window.startIframeTest = function() {
2+
var args = Array.prototype.slice.call( arguments );
3+
4+
// Note: jQuery may be undefined if page did not load it
5+
args.unshift( window.jQuery, window, document );
6+
window.parent.iframeCallback.apply( null, args );
7+
};

‎tests/unit/spinner/core.js

+14
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,20 @@ QUnit.test( "mousewheel on input (DEPRECATED)", function( assert ) {
239239
}
240240
} );
241241

242+
helper.testIframe(
243+
"wheel & mousewheel conflicts",
244+
"mousewheel-wheel.html",
245+
function( assert, jQuery, window, document, values ) {
246+
assert.expect( 5 );
247+
248+
assert.equal( values[ 0 ], 0, "wheel event without delta does not change value" );
249+
assert.equal( values[ 1 ], 2, "delta -1" );
250+
assert.equal( values[ 2 ], 0, "delta 0.2" );
251+
assert.equal( values[ 3 ], -2, "delta 15" );
252+
assert.equal( values[ 4 ], -2, "wheel when not focused" );
253+
}
254+
);
255+
242256
QUnit.test( "reading HTML5 attributes", function( assert ) {
243257
assert.expect( 6 );
244258
var markup = "<input type='number' min='-100' max='100' value='5' step='2'>",
+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<title>jQuery UI Spinner Test Suite</title>
6+
7+
<script src="../../../external/requirejs/require.js"></script>
8+
<script src="../../../external/jquery/jquery.js"></script>
9+
<script src="../../lib/css.js" data-modules="core button spinner theme"></script>
10+
<script src="../../lib/testIframe.js"></script>
11+
</head>
12+
<body>
13+
14+
<input id="spin" class="foo">
15+
16+
<script>
17+
function runTest() {
18+
var values = [],
19+
element = $( "#spin" ).val( 0 ).spinner( {
20+
step: 2
21+
} );
22+
23+
element.focus();
24+
setTimeout( step1 );
25+
26+
function dispatchWheelEvent( elem, deltaY ) {
27+
elem[ 0 ].dispatchEvent( new WheelEvent( "wheel", {
28+
deltaY: deltaY
29+
} ) );
30+
}
31+
32+
function step1() {
33+
dispatchWheelEvent( element );
34+
values.push( element.val() );
35+
36+
dispatchWheelEvent( element, -1 );
37+
values.push( element.val() );
38+
39+
dispatchWheelEvent( element, 0.2 );
40+
values.push( element.val() );
41+
42+
dispatchWheelEvent( element, 15 );
43+
values.push( element.val() );
44+
45+
element.blur();
46+
setTimeout( step2 );
47+
}
48+
49+
function step2() {
50+
dispatchWheelEvent( element, -1 );
51+
values.push( element.val() );
52+
53+
startIframeTest( values );
54+
}
55+
}
56+
57+
requirejs.config( {
58+
paths: {
59+
"jquery-mousewheel": "../../../external/jquery-mousewheel/jquery.mousewheel",
60+
"ui": "../../../ui"
61+
},
62+
} );
63+
64+
require( [
65+
"jquery-mousewheel",
66+
"ui/widgets/spinner"
67+
], function() {
68+
runTest();
69+
} );
70+
</script>
71+
</body>
72+
</html>

‎ui/widgets/spinner.js

+7
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,13 @@ $.widget( "ui.spinner", {
164164
// event. The `delta` parameter is provided by the jQuery Mousewheel
165165
// plugin if one is loaded.
166166
mousewheel: function( event, delta ) {
167+
if ( !event.isTrigger ) {
168+
169+
// If this is not a trigger call, the `wheel` handler will
170+
// fire as well, let's not duplicate it.
171+
return;
172+
}
173+
167174
var wheelEvent = $.Event( event );
168175
wheelEvent.type = "wheel";
169176
if ( delta ) {

0 commit comments

Comments
 (0)
Please sign in to comment.