/** * TextInputWidgets, like HTML text inputs, can be configured with options that customize the * size of the field as well as its presentation. In addition, these widgets can be configured * with {@link OO.ui.IconElement icons}, {@link OO.ui.IndicatorElement indicators}, an optional * validation-pattern (used to determine if an input value is valid or not) and an input filter, * which modifies incoming values rather than validating them. * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples. * * This widget can be used inside a HTML form, such as a OO.ui.FormLayout. * * @example * // Example of a text input widget * var textInput = new OO.ui.TextInputWidget( { * value: 'Text input' * } ) * $( 'body' ).append( textInput.$element ); * * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs * * @class * @extends OO.ui.InputWidget * @mixins OO.ui.IconElement * @mixins OO.ui.IndicatorElement * @mixins OO.ui.PendingElement * @mixins OO.ui.LabelElement * * @constructor * @param {Object} [config] Configuration options * @cfg {string} [type='text'] The value of the HTML `type` attribute * @cfg {string} [placeholder] Placeholder text * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to * instruct the browser to focus this widget. * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input. * @cfg {number} [maxLength] Maximum number of characters allowed in the input. * @cfg {boolean} [multiline=false] Allow multiple lines of text * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content. * Use the #maxRows config to specify a maximum number of displayed rows. * @cfg {boolean} [maxRows=10] Maximum number of rows to display when #autosize is set to true. * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of * the value or placeholder text: `'before'` or `'after'` * @cfg {boolean} [required=false] Mark the field as required * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' * (the value must contain only numbers); when RegExp, a regular expression that must match the * value for it to be considered valid; when Function, a function receiving the value as parameter * that must return true, or promise resolving to true, for it to be considered valid. */ OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) { // Configuration initialization config = $.extend( { type: 'text', labelPosition: 'after', maxRows: 10 }, config ); // Parent constructor OO.ui.TextInputWidget.super.call( this, config ); // Mixin constructors OO.ui.IconElement.call( this, config ); OO.ui.IndicatorElement.call( this, config ); OO.ui.PendingElement.call( this, config ); OO.ui.LabelElement.call( this, config ); // Properties this.readOnly = false; this.multiline = !!config.multiline; this.autosize = !!config.autosize; this.maxRows = config.maxRows; this.validate = null; // Clone for resizing if ( this.autosize ) { this.$clone = this.$input .clone() .insertAfter( this.$input ) .attr( 'aria-hidden', 'true' ) .addClass( 'oo-ui-element-hidden' ); } this.setValidation( config.validate ); this.setLabelPosition( config.labelPosition ); // Events this.$input.on( { keypress: this.onKeyPress.bind( this ), blur: this.onBlur.bind( this ) } ); this.$input.one( { focus: this.onElementAttach.bind( this ) } ); this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) ); this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) ); this.on( 'labelChange', this.updatePosition.bind( this ) ); this.connect( this, { change: 'onChange' } ); // Initialization this.$element .addClass( 'oo-ui-textInputWidget' ) .append( this.$icon, this.$indicator ); this.setReadOnly( !!config.readOnly ); if ( config.placeholder ) { this.$input.attr( 'placeholder', config.placeholder ); } if ( config.maxLength !== undefined ) { this.$input.attr( 'maxlength', config.maxLength ); } if ( config.autofocus ) { this.$input.attr( 'autofocus', 'autofocus' ); } if ( config.required ) { this.$input.attr( 'required', 'required' ); this.$input.attr( 'aria-required', 'true' ); } if ( this.label || config.autosize ) { this.installParentChangeDetector(); } }; /* Setup */ OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget ); OO.mixinClass( OO.ui.TextInputWidget, OO.ui.IconElement ); OO.mixinClass( OO.ui.TextInputWidget, OO.ui.IndicatorElement ); OO.mixinClass( OO.ui.TextInputWidget, OO.ui.PendingElement ); OO.mixinClass( OO.ui.TextInputWidget, OO.ui.LabelElement ); /* Static properties */ OO.ui.TextInputWidget.static.validationPatterns = { 'non-empty': /.+/, integer: /^\d+$/ }; /* Events */ /** * An `enter` event is emitted when the user presses 'enter' inside the text box. * * Not emitted if the input is multiline. * * @event enter */ /* Methods */ /** * Handle icon mouse down events. * * @private * @param {jQuery.Event} e Mouse down event * @fires icon */ OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) { if ( e.which === 1 ) { this.$input[ 0 ].focus(); return false; } }; /** * Handle indicator mouse down events. * * @private * @param {jQuery.Event} e Mouse down event * @fires indicator */ OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) { if ( e.which === 1 ) { this.$input[ 0 ].focus(); return false; } }; /** * Handle key press events. * * @private * @param {jQuery.Event} e Key press event * @fires enter If enter key is pressed and input is not multiline */ OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) { if ( e.which === OO.ui.Keys.ENTER && !this.multiline ) { this.emit( 'enter', e ); } }; /** * Handle blur events. * * @private * @param {jQuery.Event} e Blur event */ OO.ui.TextInputWidget.prototype.onBlur = function () { this.setValidityFlag(); }; /** * Handle element attach events. * * @private * @param {jQuery.Event} e Element attach event */ OO.ui.TextInputWidget.prototype.onElementAttach = function () { // Any previously calculated size is now probably invalid if we reattached elsewhere this.valCache = null; this.adjustSize(); this.positionLabel(); }; /** * Handle change events. * * @param {string} value * @private */ OO.ui.TextInputWidget.prototype.onChange = function () { this.setValidityFlag(); this.adjustSize(); }; /** * Check if the input is {@link #readOnly read-only}. * * @return {boolean} */ OO.ui.TextInputWidget.prototype.isReadOnly = function () { return this.readOnly; }; /** * Set the {@link #readOnly read-only} state of the input. * * @param {boolean} state Make input read-only * @chainable */ OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) { this.readOnly = !!state; this.$input.prop( 'readOnly', this.readOnly ); return this; }; /** * Support function for making #onElementAttach work across browsers. * * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback. * * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the * first time that the element gets attached to the documented. */ OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () { var mutationObserver, onRemove, topmostNode, fakeParentNode, MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver, widget = this; if ( MutationObserver ) { // The new way. If only it wasn't so ugly. if ( this.$element.closest( 'html' ).length ) { // Widget is attached already, do nothing. This breaks the functionality of this function when // the widget is detached and reattached. Alas, doing this correctly with MutationObserver // would require observation of the whole document, which would hurt performance of other, // more important code. return; } // Find topmost node in the tree topmostNode = this.$element[0]; while ( topmostNode.parentNode ) { topmostNode = topmostNode.parentNode; } // We have no way to detect the $element being attached somewhere without observing the entire // DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the // parent node of $element, and instead detect when $element is removed from it (and thus // probably attached somewhere else). If there is no parent, we create a "fake" one. If it // doesn't get attached, we end up back here and create the parent. mutationObserver = new MutationObserver( function ( mutations ) { var i, j, removedNodes; for ( i = 0; i < mutations.length; i++ ) { removedNodes = mutations[ i ].removedNodes; for ( j = 0; j < removedNodes.length; j++ ) { if ( removedNodes[ j ] === topmostNode ) { setTimeout( onRemove, 0 ); return; } } } } ); onRemove = function () { // If the node was attached somewhere else, report it if ( widget.$element.closest( 'html' ).length ) { widget.onElementAttach(); } mutationObserver.disconnect(); widget.installParentChangeDetector(); }; // Create a fake parent and observe it fakeParentNode = $( '
' ).append( this.$element )[0]; mutationObserver.observe( fakeParentNode, { childList: true } ); } else { // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated. this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) ); } }; /** * Automatically adjust the size of the text input. * * This only affects #multiline inputs that are {@link #autosize autosized}. * * @chainable */ OO.ui.TextInputWidget.prototype.adjustSize = function () { var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError, idealHeight; if ( this.multiline && this.autosize && this.$input.val() !== this.valCache ) { this.$clone .val( this.$input.val() ) .attr( 'rows', '' ) // Set inline height property to 0 to measure scroll height .css( 'height', 0 ); this.$clone.removeClass( 'oo-ui-element-hidden' ); this.valCache = this.$input.val(); scrollHeight = this.$clone[ 0 ].scrollHeight; // Remove inline height property to measure natural heights this.$clone.css( 'height', '' ); innerHeight = this.$clone.innerHeight(); outerHeight = this.$clone.outerHeight(); // Measure max rows height this.$clone .attr( 'rows', this.maxRows ) .css( 'height', 'auto' ) .val( '' ); maxInnerHeight = this.$clone.innerHeight(); // Difference between reported innerHeight and scrollHeight with no scrollbars present // Equals 1 on Blink-based browsers and 0 everywhere else measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight; idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError ); this.$clone.addClass( 'oo-ui-element-hidden' ); // Only apply inline height when expansion beyond natural height is needed if ( idealHeight > innerHeight ) { // Use the difference between the inner and outer height as a buffer this.$input.css( 'height', idealHeight + ( outerHeight - innerHeight ) ); } else { this.$input.css( 'height', '' ); } } return this; }; /** * @inheritdoc * @private */ OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) { return config.multiline ? $( '