Daniel Werner has uploaded a new change for review.

  https://gerrit.wikimedia.org/r/77479


Change subject: Improved Performance of jQuery.ui.inputextender
......................................................................

Improved Performance of jQuery.ui.inputextender

This is done by no longer initializing the input extender's extension's content 
on widget
initialization. Instead, the content will be initialized when the extension is 
first going
to be shown.
Introduces a series of minor changes
- Renamed "_draw" to "draw" and let it handle all draw operations including 
repositioning of the
  extension. Allows to reposition the extension from the outside now if 
required.
- Introduces $.ui.inputextender.redrawVisibleExtensions for easily redrawing 
all visible extensions
  of all instances. Does no longer listen to a "animationstep" event on the 
"html" element which
  triggered the repositioning of active widget instances previously.
- No more ".ui-inputextender-contentnode" class is added on the extension's 
content nodes. This is
  because it is a valid use case to not specify the "content" option and 
instead manually define
  extension's content nodes during the initCallback gets called.
- When "destroy" gets called, "hideExtension" will be called but the animation 
will be stopped and
  forwarded to its end immediately. This way the "animationstep" event will be 
triggered a last
  time before destruction, giving event listeners a chance to react to the 
extension's removal.
- Documentation updates.
- More QUnit tests.

Change-Id: I23cded705ce4954644e4e5ea58c9119f2a1e8a3e
---
M ValueView/resources/jquery.ui/jquery.ui.inputextender.css
M ValueView/resources/jquery.ui/jquery.ui.inputextender.js
M 
ValueView/resources/jquery.valueview/valueview.experts/experts.GlobeCoordinateInput.js
M ValueView/resources/jquery.valueview/valueview.experts/experts.TimeInput.js
M ValueView/tests/qunit/jquery.ui/jquery.ui.inputextender.tests.js
5 files changed, 599 insertions(+), 243 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/DataValues 
refs/changes/79/77479/1

diff --git a/ValueView/resources/jquery.ui/jquery.ui.inputextender.css 
b/ValueView/resources/jquery.ui/jquery.ui.inputextender.css
index 2cd24fb..20cef2a 100644
--- a/ValueView/resources/jquery.ui/jquery.ui.inputextender.css
+++ b/ValueView/resources/jquery.ui/jquery.ui.inputextender.css
@@ -14,9 +14,6 @@
 
 .ui-inputextender-extension > * {
        font-size: 84%;
-}
-
-.ui-inputextender-extension .ui-inputextender-contentnode {
        float: left;
        clear: both;
        margin-top: 0.5em;
@@ -26,6 +23,7 @@
        position: absolute;
        top: 0;
        right: 0;
+       margin-top: 0;
        border: none !important;
        background: none !important;
        cursor: pointer;
diff --git a/ValueView/resources/jquery.ui/jquery.ui.inputextender.js 
b/ValueView/resources/jquery.ui/jquery.ui.inputextender.js
index 7172dae..1f3d792 100644
--- a/ValueView/resources/jquery.ui/jquery.ui.inputextender.js
+++ b/ValueView/resources/jquery.ui/jquery.ui.inputextender.js
@@ -4,19 +4,17 @@
  * The input extender extends an input element with additional contents 
displayed underneath the.
  * @licence GNU GPL v2+
  * @author H. Snater < [email protected] >
+ * @author Daniel Werner < [email protected] >
  *
  * @option {jQuery[]} [content] Default/"fixed" extender contents that always 
should be visible as
  *         long as the extension itself is visible.
  *         Default value: []
  *
- * @option {jQuery[]} [extendedContent] Additional content that should only be 
displayed after
- *         clicking on the extender link.
- *         Default value: []
- *
- * @option {Function} [initCallback] Function triggered after the widget has 
been initialized but
- *         before the widget contents get hidden initially. This may be used 
to init some widgets
- *         that need to be visible on initialization for measuring dimension 
according to their
- *         container's styles.
+ * @option {Function} [initCallback] Function triggered before the extension 
is being shown for
+ *         the first time. This may be used to init some widgets that need to 
be visible on
+ *         initialization for measuring dimension according to their 
container's styles.
+ *         Context of the callback is the widget, first parameter is the 
extension's DOM in a jQuery
+ *         container.
  *
  * @option {boolean} [hideWhenInputEmpty] Whether all of the input extender's 
contents shall be
  *         hidden when the associated input element is empty.
@@ -30,8 +28,8 @@
  *        animation's "step" callback. However, when the animation is 
finished, the event is
  *        triggered without the second and third parameter.
  *        (1) {jQuery.Event}
- *        (2) {number} [now]
- *        (3) {jQuery.Tween} [tween]
+ *        (2) {number|undefined} [now]
+ *        (3) {jQuery.Tween|undefined} [tween]
  *
  * @dependency jQuery.Widget
  * @dependency jQuery.eachchange
@@ -45,6 +43,35 @@
         * @type {boolean}
         */
        var isRtl = $( 'body' ).hasClass( 'rtl' );
+
+       /**
+        * Collection for keeping track which input extender widgets have their 
extension shown at the
+        * moment.
+        * @type {jQuery.ui.InputExtender}
+        */
+       var inputExtendersWithVisibleExtension = ( function() {
+               var inputExtenders = [];
+               return {
+                       add: function( inputExtenderInstance ) {
+                               var index = $.inArray( inputExtenderInstance, 
inputExtenders );
+                               if( index < 0 ) {
+                                       inputExtenders.push( 
inputExtenderInstance );
+                               }
+                       },
+                       remove: function( inputExtenderInstance ) {
+                               var index = $.inArray( inputExtenderInstance, 
inputExtenders );
+                               if( index > -1 ) {
+                                       inputExtenderInstance.splice( index, 1 
);
+                               }
+                       },
+                       get: function() {
+                               // Make sure this is up to date and they are 
really visible.
+                               return $.grep( inputExtenders, function( 
inputExtenderInstance ) {
+                                       return 
inputExtenderInstance.extensionIsVisible();
+                               } );
+                       }
+               };
+       }() );
 
        $.widget( 'ui.inputextender', {
                /**
@@ -64,10 +91,21 @@
                },
 
                /**
-                * The input extension's node.
-                * @type {jQuery}
+                * The input extension's node. Will be null until the extension 
is required for the first
+                * time.
+                *
+                * @private Use extension() instead. extension() will return 
null if the $extension is not
+                *          being used. $extension might be destroyed in that 
case in future versions, so
+                *          do not rely on it being set all of the time after 
its first initialization.
+                * @type {jQuery|null}
                 */
                $extension: null,
+
+               /**
+                * Whether the input extender is in its extended state right 
now.
+                * @type {boolean}
+                */
+               _isExtended: false,
 
                /**
                 * Caches the timeout when the actual input extender animation 
should kick in.
@@ -88,69 +126,39 @@
                _create: function() {
                        var self = this;
 
-                       this.element
-                       .addClass( this.widgetBaseClass + '-input' );
+                       this.element.addClass( this.widgetBaseClass + '-input' 
);
 
-                       // TODO: Extension should only be built on demand.
-                       this.$extension = $( '<div/>' )
-                       .addClass( this.widgetBaseClass + '-extension 
ui-widget-content' )
-                       .on( 'click.' + this.widgetName, function( event ) {
-                               if( !$( event.target ).closest( self.$closeIcon 
).length ) {
-                                       clearTimeout( self._animationTimeout );
-                                       self.showExtension();
-                               }
-                       } )
-                       .on( 'toggleranimationstep.' + this.widgetName, 
function( event, now, tween ) {
-                               self._reposition();
-                               self._trigger( 'animationstep', null, [ now, 
tween ] );
-                       } )
-                       .on( 'keydown.' + this.widgetName, function( event ) {
-                               if( event.keyCode === $.ui.keyCode.TAB ) {
-                                       var $focusable = self.$extension.find( 
':focusable' );
-                                       if( event.target === 
$focusable.first()[0] && event.shiftKey ) {
-                                               event.preventDefault();
-                                               // Tab back to the input 
element:
-                                               self.element.focus();
-                                       } else if ( event.target === 
$focusable.last()[0] && !event.shiftKey ) {
-                                               event.preventDefault();
-                                               // Tabbing forward out of the 
extension: Focus the next focusable element
-                                               // after the input element.
-                                               $focusable = $( ':focusable' );
-                                               $focusable.each( function( i, 
node ) {
-                                                       if( node === 
self.element[0] ) {
-                                                               
self.hideExtension();
-                                                               $focusable[ ( i 
+ 1 >= $focusable.length ) ? 0 : i + 1 ].focus();
-                                                       }
-                                               } );
-                                       }
-                               }
-                       } )
-                       .appendTo( $( 'body' ) );
-
+                       // TODO: focus per mouse and tab should be treated 
differently. Focus by tab should
+                       //  leave enough time to tab again, by mouse the 
extension can be shown immediately.
                        this.element
                        .on( 'focus.' + this.widgetName, function( event ) {
                                if( !self.options.hideWhenInputEmpty || 
self.element.val() !== '' ) {
                                        clearTimeout( self._animationTimeout );
                                        self._animationTimeout = setTimeout( 
function() {
                                                self.showExtension();
-                                       }, 150 );
+                                       }, 250 );
                                }
                        } )
                        .on( 'blur.' + this.widgetName, function( event ) {
+                               clearTimeout( self._animationTimeout );
                                self._animationTimeout = setTimeout( function() 
{
                                        self.hideExtension();
-                               }, 150 );
+                               }, 250 );
                        } )
                        .on( 'keydown.' + this.widgetName, function( event ) {
                                if( event.keyCode === $.ui.keyCode.ESCAPE ) {
                                        self.hideExtension();
-                               } else if ( event.keyCode === $.ui.keyCode.TAB 
&& !event.shiftKey ) {
+                               }
+                               else if(
+                                       self.extensionIsVisible()
+                                       && event.keyCode === $.ui.keyCode.TAB 
&& !event.shiftKey
+                               ) {
                                        event.preventDefault();
                                        // When tabbing out of the input 
element, focus the first focusable element
                                        // within the extension.
-                                       var focusable = self.$extension.find( 
':focusable' );
-                                       if( focusable.length ) {
-                                               focusable.first().focus();
+                                       var $focusable = self.$extension.find( 
':focusable' );
+                                       if( $focusable.length ) {
+                                               $focusable.first().focus();
                                                clearTimeout( 
self._animationTimeout );
                                        }
                                }
@@ -182,68 +190,52 @@
                                        }
 
                                } );
-                       } )
-                       // If some other animation changes the input element's 
position, the input extender
-                       // needs to be repositioned:
-                       .on( 'animationstep.' + this.widgetName, function( 
event, now, tween ) {
-                               $( ':' + self.widgetBaseClass ).each( function( 
i, widgetNode ) {
-                                       var widget = $( widgetNode ).data( 
self.widgetName );
-                                       if( widget.$extension.is( ':visible' ) 
) {
-                                               widget._reposition();
-                                       }
-                               } );
                        } );
 
-                       this.$closeIcon = $( '<div/>' )
-                       .addClass( this.widgetBaseClass + '-extension-close 
ui-state-default' )
-                       .on( 'mouseover.' + this.widgetName, function( event ) {
-                               $( this ).addClass( 'ui-state-hover' );
-                       } )
-                       .on( 'mouseout.' + this.widgetName, function( event ) {
-                               $( this ).removeClass( 'ui-state-hover' );
-                       } )
-                       .on( 'click.' + this.widgetName, function( event ) {
-                               self.hideExtension();
-                       } )
-                       .append( $( '<div/>' ).addClass( 'ui-icon 
ui-icon-close' ) );
-
-                       this._draw();
-
-                       if( $.isFunction( this.options.initCallback ) ) {
-                               this.options.initCallback();
+                       if( this.element.is( ':focus' ) ) {
+                               this.showExtension();
+                       } else {
+                               this.draw();
                        }
-
-                       $.each( this.options.content, function( i, node ) {
-                               $( node ).addClass( self.widgetBaseClass + 
'-contentnode' );
-                       } );
-
-                       this.$extension.hide();
                },
 
                /**
                 * @see jQuery.Widget.destroy
                 */
                destroy: function() {
-                       this.$extension.remove();
+                       var self = this;
+                       function finalCleanup() {
+                               self.$extension.remove();
+                               self.$extension = null;
+                       }
+
+                       if( this.extensionIsActive() ) {
+                               this.hideExtension( finalCleanup );
+                               this.$extension.stop( false, true );
+                       }
+                       else if( this.$extension ) {
+                               finalCleanup();
+                       }
 
                        $.Widget.prototype.destroy.call( this );
-
-                       if( $( ':' + this.widgetBaseClass ).length === 0 ) {
-                               $( 'html' ).off( '.' + this.widgetName );
-                       }
                },
 
-               /**
-                * Draws the widget.
-                */
-               _draw: function() {
+               degrade: function() {
                        var self = this;
+                       var finalCleanupHandler = function( e, now, tween ) {
+                               if( tween === undefined ) {
+                                       self.element.off( 
this.widgetEventPrefix + 'animationstep', finalCleanupHandler );
+                                       self.destroy();
+                               }
+                       };
 
-                       this.$extension.empty().append( this.$closeIcon );
-
-                       $.each( this.options.content, function( i, $node ) {
-                               self.$extension.append( $node );
-                       } );
+                       if( this.extensionIsVisible() ) {
+                               this.element.on( 'inputextenderanimationstep', 
finalCleanupHandler );
+                               this.hideExtension();
+                       }
+                       else if( self.$extension ) {
+                               this.destroy();
+                       }
                },
 
                /**
@@ -252,6 +244,113 @@
                 * @param {Function} [callback] Invoked as soon as the contents 
are visible.
                 */
                showExtension: function( callback ) {
+                       if( !this._isExtended ) {
+                               this._isExtended = true;
+                               this.draw( callback );
+                       }
+               },
+
+               /**
+                * Hides the extension.
+                *
+                * @param {Function} [callback] Invoked as soon as the contents 
are hidden.
+                */
+               hideExtension: function( callback ) {
+                       if( this._isExtended ) {
+                               this._isExtended = false;
+                               this.draw( callback );
+                       }
+               },
+
+               /**
+                * Returns the input extension's node or null in case the 
extension is currently not in its
+                * visible state.
+                *
+                * @returns {jQuery|null}
+                */
+               extension: function() {
+                       return this.extensionIsVisible() ? this.$extension : 
null;
+               },
+
+               /**
+                * Returns whether the extension is currently active.
+                *
+                * @returns {boolean}
+                */
+               extensionIsActive: function() {
+                       return this._isExtended;
+               },
+
+               /**
+                * Returns whether the extension is currently visible. Will 
still return true after
+                * hideExtension() got called until the hide animation will be 
completed.
+                *
+                * @returns {boolean}
+                */
+               extensionIsVisible: function() {
+                       if( !this.$extension ) {
+                               return false;
+                       }
+                       return this.$extension.is( ':visible' );
+               },
+
+               /**
+                * Draws the widget.
+                *
+                * @param {Function} [callback] For private usage only.
+                * TODO: Get rid of "callback", introduce some sort of 
"animationstart" event instead,
+                *  offering an object allowing to register callback that will 
be given into the animation's
+                *  options. show/hideExtension can then do a .one() event 
registration for that one and
+                *  register their callback there.
+                */
+               draw: function( /* private: */ callback ) {
+                       this.element[ this._isExtended ? 'addClass' : 
'removeClass' ](
+                               this.widgetBaseClass + '-extended' );
+
+                       this._drawExtension( callback );
+               },
+
+               _drawExtension: function( callback ) {
+                       var extensionIsVisible = this.extensionIsVisible(),
+                               $extension = this.$extension;
+
+                       if( !extensionIsVisible && !this._isExtended ) {
+                               // Extension not displayed and not supposed to 
be displayed.
+                               return;
+                       }
+
+                       if( !$extension ) {
+                               this.$extension = $extension = 
this._buildExtension();
+                               $extension.appendTo( $( 'body' ) );
+
+                               // TODO
+                               if( $.isFunction( this.options.initCallback ) ) 
{
+                                       $extension.show();
+                                       this.options.initCallback.call( this, 
$extension );
+                                       $extension.hide();
+                               }
+                       }
+
+                       // Element needs to be visible to use 
jquery.ui.position.
+                       if( !extensionIsVisible ) {
+                               $extension.show();
+                               this._reposition();
+                               $extension.hide();
+                       } else {
+                               this._reposition();
+                       }
+
+                       if( extensionIsVisible !== this._isExtended ) {
+                               // Represent actual expansion status:
+                               if( this._isExtended ) {
+                                       this._drawExtensionExpansion( callback 
);
+                               } else {
+                                       this._drawExtensionRemoval( callback );
+                               }
+                       }
+               },
+
+               _drawExtensionExpansion: function( callback ) {
                        var self = this;
 
                        // When blurring the browser viewport and an 
re-focusing, Chrome is firing the "focus"
@@ -259,13 +358,6 @@
                        // pick up the value when triggering fadeIn the second 
time.
                        if( this.$extension.css( 'opacity' ) === '0' ) {
                                this.$extension.css( 'opacity', '1' );
-                       }
-
-                       // Element needs to be visible to use 
jquery.ui.position.
-                       if( !this.$extension.is( ':visible' ) ) {
-                               this.$extension.show();
-                               this._reposition();
-                               this.$extension.hide();
                        }
 
                        this.$extension.stop( true ).fadeIn( {
@@ -280,14 +372,10 @@
                                        self._trigger( 'animationstep' );
                                }
                        } );
+                       inputExtendersWithVisibleExtension.add( this );
                },
 
-               /**
-                * Hides the extension.
-                *
-                * @param {Function} [callback] Invoked as soon as the contents 
are hidden.
-                */
-               hideExtension: function( callback ) {
+               _drawExtensionRemoval: function( callback ) {
                        var self = this;
 
                        this.$extension.stop( true ).fadeOut( {
@@ -296,6 +384,7 @@
                                        self._trigger( 'animationstep', null, [ 
now, tween ] );
                                },
                                complete: function() {
+                                       
inputExtendersWithVisibleExtension.remove( this );
                                        if( $.isFunction( callback ) ) {
                                                callback();
                                        }
@@ -325,8 +414,93 @@
                        }, this.options.position ) );
 
                        this._offset = offset;
-               }
+               },
 
+               _buildExtension: function() {
+                       var self = this,
+                               $extension = $( '<div/>' );
+
+                       $extension.addClass( this.widgetBaseClass + '-extension 
ui-widget-content' )
+                       .on( 'click.' + this.widgetName, function( event ) {
+                               if( !$( event.target ).closest( self.$closeIcon 
).length ) {
+                                       clearTimeout( self._animationTimeout );
+                                       self.showExtension();
+                               }
+                       } )
+                       // TODO: move this out of here, toggler is not even 
used/known to this widget:
+                       .on( 'toggleranimationstep.' + this.widgetName, 
function( event, now, tween ) {
+                               self._reposition();
+                               self._trigger( 'animationstep', null, [ now, 
tween ] );
+                       } )
+                       .on( 'keydown.' + this.widgetName, function( event ) {
+                               // Take care of tabbing out of the extension 
again:
+                               if( event.keyCode === $.ui.keyCode.TAB ) {
+                                       var $focusable = self.$extension.find( 
':focusable' );
+
+                                       if( $focusable.first().is( event.target 
) && event.shiftKey ) {
+                                               event.preventDefault();
+                                               // Tab back to the input 
element:
+                                               self.element.focus();
+                                       }
+                                       else if( $focusable.last().is( 
event.target ) && !event.shiftKey ) {
+                                               event.preventDefault();
+                                               // Tabbing forward out of the 
extension: Focus the next focusable element
+                                               // after the input element.
+                                               $focusable = $( ':focusable' );
+                                               $focusable.each( function( i, 
node ) {
+                                                       if( self.element.is( 
node ) ) {
+                                                               
self.hideExtension();
+                                                               $focusable[ ( i 
+ 1 >= $focusable.length ) ? 0 : i + 1 ].focus();
+                                                       }
+                                               } );
+                                       }
+                               }
+                       } );
+
+                       var $closeButton = this._buildExtensionCloseButton();
+                       return $extension.append( $closeButton ).append( 
this.options.content );
+               },
+
+               _buildExtensionCloseButton: function() {
+                       var self = this,
+                               $closeButton = $( '<div/>' ),
+                               $closeIcon = $( '<div/>' ).addClass( 'ui-icon 
ui-icon-close' );
+
+                       $closeButton.addClass( this.widgetBaseClass + 
'-extension-close ui-state-default' );
+
+                       $closeButton.on( 'mouseover.' + this.widgetName, 
function( event ) {
+                               $( this ).addClass( 'ui-state-hover' );
+                       } )
+                       .on( 'mouseout.' + this.widgetName, function( event ) {
+                               $( this ).removeClass( 'ui-state-hover' );
+                       } )
+                       .on( 'click.' + this.widgetName, function( event ) {
+                               self.hideExtension();
+                       } )
+                       .append( $closeIcon );
+
+                       return $closeButton;
+               }
        } );
 
+       /**
+        * Returns all the widget instances with currently visible extensions.
+        *
+        * @returns {jQuery.ui.inputextender[]}
+        */
+       $.ui.inputextender.getInstancesWithVisibleExtensions = function() {
+               return inputExtendersWithVisibleExtension.get();
+       };
+
+       /**
+        * Will redraw all currently visible extensions of all input extender 
instances.
+        * This is useful when changing the DOM, making sure that extensions 
are still next to their
+        * input boxes in case position of the input boxes has changed.
+        */
+       $.ui.inputextender.redrawVisibleExtensions = function() {
+               $.each( $.ui.inputextender.getInstancesWithVisibleExtensions(), 
function( i, instance ) {
+                       instance.draw();
+               } );
+       };
+
 } )( jQuery );
diff --git 
a/ValueView/resources/jquery.valueview/valueview.experts/experts.GlobeCoordinateInput.js
 
b/ValueView/resources/jquery.valueview/valueview.experts/experts.GlobeCoordinateInput.js
index 4217026..6cf9301 100644
--- 
a/ValueView/resources/jquery.valueview/valueview.experts/experts.GlobeCoordinateInput.js
+++ 
b/ValueView/resources/jquery.valueview/valueview.experts/experts.GlobeCoordinateInput.js
@@ -71,8 +71,32 @@
                 * @see jQuery.valueview.Expert._init
                 */
                _init: function() {
+                       var self = this;
+
+                       this.$input = $( '<input/>', {
+                               type: 'text',
+                               'class': this.uiBaseClass + '-input 
valueview-input'
+                       } )
+                       .appendTo( this.$viewPort )
+                       .eachchange( function( event, oldValue ) {
+                               this._viewNotifier.notify( 'change' );
+                       } )
+                       .inputextender( {
+                               initCallback: function( $extension ) {
+                                       self._initInputExtender( $extension );
+                               }
+                       } );
+               },
+
+               /**
+                * Initializes the input extender with the required content 
into the given DOM element.
+                *
+                * TODO: Split this up. Share code with similar experts (Time).
+                *
+                * @param {jQuery} $extension
+                */
+               _initInputExtender: function( $extension ) {
                        var self = this,
-                               notifier = this._viewNotifier,
                                precisionMsgKey = 
'valueview-expert-globecoordinateinput-precision',
                                precisionValues = [],
                                listrotatorEvents = 'listrotatorauto 
listrotatorselected'
@@ -91,57 +115,49 @@
                        } );
 
                        this.$precision = $( '<div/>' )
-                               .addClass( this.uiBaseClass + '-precision' )
-                               .listrotator( {
-                                       values: precisionValues.reverse(),
-                                       deferInit: true
-                               } )
-                               .on( listrotatorEvents, function( event, 
newPrecisionLevel ) {
-                                       var currentValue = 
self.viewState().value();
+                       .addClass( this.uiBaseClass + '-precision' )
+                       .listrotator( {
+                               values: precisionValues.reverse(),
+                               deferInit: true
+                       } )
+                       .on( listrotatorEvents, function( event, 
newPrecisionLevel ) {
+                               var currentValue = self.viewState().value();
 
-                                       if( currentValue === null ) {
-                                               // current rawValue must be 
invalid anyhow
-                                               return;
-                                       }
+                               if( currentValue === null ) {
+                                       // current rawValue must be invalid 
anyhow
+                                       return;
+                               }
 
-                                       var currentPrecision = roundPrecision(
-                                                       
currentValue.getValue().getPrecision() );
+                               var currentPrecision = roundPrecision(
+                                               
currentValue.getValue().getPrecision() );
 
-                                       if( newPrecisionLevel === 
currentPrecision ) {
-                                               // Listrotator has been rotated 
automatically or the value covering the new
-                                               // precision has already been 
generated.
-                                               return;
-                                       }
+                               if( newPrecisionLevel === currentPrecision ) {
+                                       // Listrotator has been rotated 
automatically or the value covering the new
+                                       // precision has already been generated.
+                                       return;
+                               }
 
-                                       notifier.notify( 'change' );
-                               } )
-                               .appendTo( this.$precisionContainer );
+                               self._viewNotifier.notify( 'change' );
+                       } )
+                       .appendTo( this.$precisionContainer );
 
                        var $toggler = $( '<a/>' )
                        .addClass( this.uiBaseClass + '-advancedtoggler' )
                        .text( this._messageProvider.getMessage( 
'valueview-expert-advancedadjustments' ) );
 
-                       this.$input = $( '<input/>', {
-                               type: 'text',
-                               'class': this.uiBaseClass + '-input 
valueview-input'
-                       } )
-                       .appendTo( this.$viewPort );
-
                        var $preview = $( '<div/>' ).preview( { $input: 
this.$input } );
                        this.preview = $preview.data( 'preview' );
 
-                       this.$input.eachchange( function( event, oldValue ) {
-                               notifier.notify( 'change' );
-                       } )
-                       .inputextender( {
-                               content: [ $preview, $toggler, 
this.$precisionContainer ],
-                               initCallback: function() {
-                                       self.$precision.data( 'listrotator' 
).initWidths();
-                                       self.$precisionContainer.css( 
'display', 'none' );
-                                       $toggler.toggler( { $subject: 
self.$precisionContainer } );
-                               }
-                       } );
+                       // Append everything since the following actions 
require the fully initialized DOM.
+                       $extension.append( [
+                               $preview,
+                               $toggler,
+                               this.$precisionContainer
+                       ] );
 
+                       this.$precision.data( 'listrotator' ).initWidths();
+                       this.$precisionContainer.css( 'display', 'none' );
+                       $toggler.toggler( { $subject: this.$precisionContainer 
} );
                },
 
                /**
@@ -157,15 +173,17 @@
                                this.preview.element.remove();
                        }
 
-                       var listRotator = this.$precision.data( 'listrotator' );
+                       var listRotator = this.$precision && 
this.$precision.data( 'listrotator' );
                        if( listRotator ) {
                                listRotator.destroy();
                        }
 
                        var inputExtender = this.$input.data( 'inputextender' );
                        if( inputExtender ) {
-                               inputExtender.$extension.find( this.uiBaseClass 
+ '-advancedtoggler' )
-                                       .toggler( 'destroy' );
+                               if( inputExtender.$extension ) {
+                                       inputExtender.$extension.find( 
this.uiBaseClass + '-advancedtoggler' )
+                                               .toggler( 'destroy' );
+                               }
                                inputExtender.destroy();
                        }
 
@@ -278,8 +296,13 @@
                                }
                        }
 
-                       if( this._newValue
-                               || this.$precision.data( 'listrotator' 
).autoActive()
+                       var considerInputExtender = this.$input.data( 
'inputextender' ).extensionIsVisible();
+
+                       if( considerInputExtender
+                               && (
+                                       this._newValue
+                                       || this.$precision.data( 'listrotator' 
).autoActive()
+                               )
                        ) {
                                // hacky update of precision, just assume the 
raw value is the value we have in
                                // the valueview right now.
@@ -292,7 +315,9 @@
                        this._newValue = false;
 
                        // Update preview:
-                       this.preview.update( geoValue && 
geoValue.getValue().degreeText() );
+                       if( considerInputExtender ) {
+                               this.preview.update( geoValue && 
geoValue.getValue().degreeText() );
+                       }
                },
 
                /**
diff --git 
a/ValueView/resources/jquery.valueview/valueview.experts/experts.TimeInput.js 
b/ValueView/resources/jquery.valueview/valueview.experts/experts.TimeInput.js
index 89e4740..8ebbbf5 100644
--- 
a/ValueView/resources/jquery.valueview/valueview.experts/experts.TimeInput.js
+++ 
b/ValueView/resources/jquery.valueview/valueview.experts/experts.TimeInput.js
@@ -96,6 +96,47 @@
                _init: function() {
                        var self = this;
 
+                       this.$input = $( '<input/>', {
+                               type: 'text',
+                               'class': this.uiBaseClass + '-input 
valueview-input'
+                       } )
+                       .appendTo( this.$viewPort )
+                       .eachchange( function( event, oldValue ) {
+                               var value = self.$input.data( 'timeinput' 
).value();
+                               if( oldValue === '' && value === null || 
self.$input.val() === '' ) {
+                                       self._updatePreview();
+                                       self._updateCalendarHint();
+                               }
+                       } )
+                       .timeinput( { mediaWiki: this._options.mediaWiki } )
+                       .inputextender( {
+                               initCallback: function( $extension ) {
+                                       self._initInputExtender( $extension );
+                               }
+                       } )
+                       .on( 'timeinputupdate.' + this.uiBaseClass, function( 
event, value ) {
+                               self._updateCalendarHint( value );
+                               if( value ) {
+                                       self.$precision.data( 'listrotator' 
).rotate( value.precision() );
+                                       self.$calendar.data( 'listrotator' 
).rotate( value.calendar() );
+                               }
+                               self._newValue = false; // value, not yet 
handled by draw(), is outdated now
+                               self._viewNotifier.notify( 'change' );
+                               self._updatePreview();
+                       } );
+
+               },
+
+               /**
+                * Initializes the input extender with the required content 
into the given DOM element.
+                *
+                * TODO: Split this up. Share code with similar experts 
(GlobeCoordinate).
+                *
+                * @param {jQuery} $extension
+                */
+               _initInputExtender: function( $extension ) {
+                       var self = this;
+
                        this.$precisionContainer = $( '<div/>' )
                        .addClass( this.uiBaseClass + '-precisioncontainer' )
                        .append(
@@ -198,47 +239,24 @@
                        )
                        .hide();
 
-                       this.$input = $( '<input/>', {
-                               type: 'text',
-                               'class': this.uiBaseClass + '-input 
valueview-input'
-                       } )
-                       .appendTo( this.$viewPort );
-
                        var $preview = $( '<div/>' ).preview( { $input: 
this.$input } );
                        this.preview = $preview.data( 'preview' );
 
-                       this.$input.eachchange( function( event, oldValue ) {
-                               var value = self.$input.data( 'timeinput' 
).value();
-                               if( oldValue === '' && value === null || 
self.$input.val() === '' ) {
-                                       self._updatePreview();
-                                       self._updateCalendarHint();
-                               }
-                       } )
-                       .timeinput( { mediaWiki: this._options.mediaWiki } )
-                       // TODO: Move input extender out of here to a more 
generic place since it is not
-                       // TimeInput specific.
-                       .inputextender( {
-                               content: [ $preview, this.$calendarhint, 
$toggler, this.$precisionContainer, this.$calendarContainer ],
-                               initCallback: function() {
-                                       self.$precision.data( 'listrotator' 
).initWidths();
-                                       self.$calendar.data( 'listrotator' 
).initWidths();
+                       // Append everything since the following actions 
require the fully initialized DOM.
+                       $extension.append( [
+                               $preview,
+                               this.$calendarhint,
+                               $toggler,
+                               this.$precisionContainer,
+                               this.$calendarContainer
+                       ] );
 
-                                       var $subjects = 
self.$precisionContainer.add( self.$calendarContainer );
-                                       $subjects.css( 'display', 'none' );
-                                       $toggler.toggler( { $subject: $subjects 
} );
-                               }
-                       } )
-                       .on( 'timeinputupdate.' + this.uiBaseClass, function( 
event, value ) {
-                               self._updateCalendarHint( value );
-                               if( value ) {
-                                       self.$precision.data( 'listrotator' 
).rotate( value.precision() );
-                                       self.$calendar.data( 'listrotator' 
).rotate( value.calendar() );
-                               }
-                               self._newValue = false; // value, not yet 
handled by draw(), is outdated now
-                               self._viewNotifier.notify( 'change' );
-                               self._updatePreview();
-                       } );
+                       this.$precision.data( 'listrotator' ).initWidths();
+                       this.$calendar.data( 'listrotator' ).initWidths();
 
+                       var $subjects = this.$precisionContainer.add( 
this.$calendarContainer );
+                       $subjects.css( 'display', 'none' );
+                       $toggler.toggler( { $subject: $subjects } );
                },
 
                /**
@@ -261,10 +279,14 @@
 
                        var inputExtender = this.$input.data( 'inputextender' );
                        if( inputExtender ) {
-                               // Explicitly destroy calendar and precision 
list rotators:
-                               inputExtender.$extension.find( 
':ui-listrotator' ).listrotator( 'destroy' );
-                               inputExtender.$extension.find( this.uiBaseClass 
+ '-advancedtoggler' )
-                                       .toggler( 'destroy' );
+                               // TODO: implement a init/destroy callback for 
input extender's extension instead,
+                               //  only called when necessary.
+                               if( inputExtender.$extension ) {
+                                       // Explicitly destroy calendar and 
precision list rotators:
+                                       inputExtender.$extension.find( 
':ui-listrotator' ).listrotator( 'destroy' );
+                                       inputExtender.$extension.find( 
this.uiBaseClass + '-advancedtoggler' )
+                                               .toggler( 'destroy' );
+                               }
                                inputExtender.destroy();
                        }
 
@@ -444,17 +466,24 @@
                                this.$input.data( 'timeinput' ).enable();
                        }
 
+                       var considerInputExtender = this.$input.data( 
'inputextender' ).extensionIsVisible();
+
                        if( this._newValue !== false ) {
                                this.$input.data( 'timeinput' ).value( 
this._newValue );
-                               this._updateCalendarHint( this._newValue );
-                               if( this._newValue !== null ) {
-                                       this.$precision.data( 'listrotator' 
).value( this._newValue.precision() );
-                                       this.$calendar.data( 'listrotator' 
).value( this._newValue.calendar() );
+
+                               if( considerInputExtender ) {
+                                       this._updateCalendarHint( 
this._newValue );
+                                       if( this._newValue !== null ) {
+                                               this.$precision.data( 
'listrotator' ).value( this._newValue.precision() );
+                                               this.$calendar.data( 
'listrotator' ).value( this._newValue.calendar() );
+                                       }
                                }
                                this._newValue = false;
                        }
 
-                       this._updatePreview();
+                       if( considerInputExtender ) {
+                               this._updatePreview();
+                       }
                },
 
                /**
diff --git a/ValueView/tests/qunit/jquery.ui/jquery.ui.inputextender.tests.js 
b/ValueView/tests/qunit/jquery.ui/jquery.ui.inputextender.tests.js
index 1d2d17e..a7189e2 100644
--- a/ValueView/tests/qunit/jquery.ui/jquery.ui.inputextender.tests.js
+++ b/ValueView/tests/qunit/jquery.ui/jquery.ui.inputextender.tests.js
@@ -4,6 +4,7 @@
  *
  * @licence GNU GPL v2+
  * @author H. Snater < [email protected] >
+ * @author Daniel Werner < [email protected] >
  */
 
 ( function( $, QUnit ) {
@@ -11,23 +12,64 @@
 
        // TODO: Tests for hideWhenInputEmptyOption
 
+       var BROWSER_FOCUS_NOTE = '(An error at this stage might also occur if 
you removed the focus ' +
+               'from the browser window.)';
+
        /**
         * Factory for creating an input extender widget suitable for testing.
+        *
+        * @param {Object} [options] input extender options. If not given, the 
"content" option will be
+        *        set to some span node with text.
+        * @param {jQuery} [$input] Subject node for the widget.
         */
-       var newTestInputextender = function( options ) {
-               if( !options ) {
-                       options = {
-                               content: [ $( '<span/>' ).addClass( 
'defaultContent' ).text( 'default content' ) ]
-                       };
+       var newTestInputextender = function( options, $input ) {
+               if( $input === undefined && options instanceof $ ) {
+                       $input = options;
+                       options = undefined;
                }
 
-               var $input = $( '<input/>' )
-                       .addClass( 'test_inputextender' )
-                       .appendTo( $( 'body' ) )
-                       .inputextender( options );
+               options = options || {
+                               content: [ $( '<span/>' ).addClass( 
'defaultContent' ).text( 'default content' ) ]
+                       };
+
+               $input = $input || $( '<input/>' ).appendTo( $( 'body' ) );
+               $input
+               .addClass( 'test_inputextender' )
+               .inputextender( options );
 
                return $input.data( 'inputextender' );
        };
+
+       /**
+        * Convenience function for testing behavior before/after/during 
showing and hiding extension.
+        *
+        * @example <code>
+        *  showAndHideExtensionAgain( newTestInputextender(), {
+        *      afterCallingShowExtension: function( instance ) {},
+        *      whenFullyShown: function() { instance },
+        *      afterCallingHideExtension: function( instance ) {},
+        *      whenFullyHiddenAgain: function( instance ) {}
+        *  } );
+        * </code>
+        */
+       function showAndHideExtensionAgain( instance, callbacks ) {
+               instance.showExtension( function() {
+                       ( callbacks.whenFullyShown || $.noop )( instance );
+
+                       instance.hideExtension( function() {
+                               ( callbacks.whenFullyHiddenAgain || $.noop )( 
instance );
+
+                               QUnit.start(); // *2*
+                       } );
+                       ( callbacks.afterCallingHideExtension || $.noop )( 
instance );
+
+                       QUnit.stop(); // wait for hideExtension() callback *2*
+                       QUnit.start(); // *1*
+               } );
+               ( callbacks.afterCallingShowExtension || $.noop )( instance );
+
+               QUnit.stop(); // wait for showExtension() callback *1*
+       }
 
        QUnit.module( 'jquery.ui.inputextender', {
                teardown: function() {
@@ -40,9 +82,8 @@
                }
        } );
 
-       QUnit.test( 'Initialization', 4, function( assert ) {
-               var extender = newTestInputextender(),
-                       widgetBaseClass = extender.widgetBaseClass;
+       QUnit.test( 'Initialization', 2, function( assert ) {
+               var extender = newTestInputextender();
 
                assert.equal(
                        $( '.test_inputextender' ).data( 'inputextender' ),
@@ -51,9 +92,24 @@
                );
 
                assert.ok(
-                       !extender.$extension.is( ':visible' ),
-                       'Extension is not visible.'
+                       !extender.extensionIsActive(),
+                       'Extension not active initially.'
                );
+       } );
+
+       QUnit.test( 'Initialization on focused input', function( assert ) {
+               var $input = $( '<input/>' ).appendTo( $( 'body' ) ).focus(),
+                       extender = newTestInputextender( $input );
+
+               assert.ok(
+                       extender.extensionIsActive(),
+                       'Extension active initially because input has focus. ' 
+ BROWSER_FOCUS_NOTE
+               );
+       } );
+
+       QUnit.test( 'Destruction', 2, function( assert ) {
+               var extender = newTestInputextender(),
+                       widgetBaseClass = extender.widgetBaseClass;
 
                extender.destroy();
 
@@ -69,29 +125,103 @@
                );
        } );
 
-       QUnit.test( 'Show/Hide', 2, function( assert ) {
-               var extender = newTestInputextender();
-
-               QUnit.stop();
-
-               extender.showExtension( function() {
-                       assert.ok(
-                               extender.$extension.is( ':visible' ),
-                               'showExtension()'
-                       );
-
-                       QUnit.stop();
-
-                       extender.hideExtension( function() {
+       QUnit.test( 'showExtension and extensionIsVisible/extensionIsActive', 
4, function( assert ) {
+               showAndHideExtensionAgain( newTestInputextender(), {
+                       afterCallingShowExtension: function( instance ) {
                                assert.ok(
-                                       !extender.$extension.is( ':visible' ),
-                                       'hideExtension()'
+                                       instance.extensionIsActive(),
+                                       'Extension is considered "active" 
immediately after calling "showExtension".'
                                );
 
-                               QUnit.start();
-                       } );
+                               assert.ok(
+                                       instance.extensionIsVisible(),
+                                       'Extension is visible immediately after 
calling "showExtension".'
+                               );
 
-                       QUnit.start();
+                               assert.ok(
+                                       instance.extension(),
+                                       'extension() returns extension\'s DOM 
at this state.'
+                               );
+                       },
+                       whenFullyShown: function( instance ) {
+                               assert.ok(
+                                       true,
+                                       'showExtension( callback ) has 
triggered callback.'
+                               );
+                       },
+                       afterCallingHideExtension: function( instance ) {},
+                       whenFullyHiddenAgain: function( instance ) {}
+               } );
+       } );
+
+       QUnit.test( 'hideExtension and extensionIsVisible/extensionIsActive', 
6, function( assert ) {
+               showAndHideExtensionAgain( newTestInputextender(), {
+                       afterCallingShowExtension: function( instance ) {},
+                       whenFullyShown: function( instance ) {},
+                       afterCallingHideExtension: function( instance ) {
+                               assert.ok(
+                                       !instance.extensionIsActive(),
+                                       'Extension is considered "inactive" 
immediately after calling "hideExtension".'
+                               );
+
+                               assert.ok(
+                                       instance.extensionIsVisible(),
+                                       'Extension is still visible immediately 
after calling "hideExtension".'
+                               );
+
+                               assert.ok(
+                                       instance.extension(),
+                                       'extension() still returns extension\'s 
DOM at this state.'
+                               );
+                       },
+                       whenFullyHiddenAgain: function( instance ) {
+                               assert.ok(
+                                       true,
+                                       'hideExtension( callback ) has 
triggered callback.'
+                               );
+
+                               assert.ok(
+                                       !instance.extensionIsVisible(),
+                                       'Extension is not visible anymore when 
callback gets called after "hide" is done.'
+                               );
+
+                               assert.strictEqual(
+                                       instance.extension(),
+                                       null,
+                                       'extension() no longer returns null at 
this state.'
+                               );
+                       }
+               } );
+       } );
+
+       QUnit.test( '$.ui.inputextender.getInstancesWithVisibleExtensions', 3, 
function( assert ) {
+               function getThem() {
+                       return 
$.ui.inputextender.getInstancesWithVisibleExtensions();
+               }
+
+               assert.ok(
+                       $.isArray( getThem() ) && getThem().length === 0,
+                       'Returns empty array.'
+               );
+
+               var extender = newTestInputextender();
+
+               showAndHideExtensionAgain( extender, {
+                       afterCallingShowExtension: function( instance ) {
+                               assert.ok(
+                                       getThem().length === 1 && getThem()[0] 
=== instance,
+                                       'Returns instance immediately after 
calling showExtension() on instance.'
+                               );
+                       },
+                       whenFullyShown: function( instance ) {},
+                       afterCallingHideExtension: function( instance ) {},
+                       whenFullyHiddenAgain: function( instance ) {
+                               assert.equal(
+                                       getThem().length,
+                                       0,
+                                       'Instance no longer returned in 
hideExtension( callback )\'s callback.'
+                               );
+                       }
                } );
        } );
 

-- 
To view, visit https://gerrit.wikimedia.org/r/77479
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: I23cded705ce4954644e4e5ea58c9119f2a1e8a3e
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/extensions/DataValues
Gerrit-Branch: master
Gerrit-Owner: Daniel Werner <[email protected]>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to