diff options
Diffstat (limited to 'vendor/oojs/oojs-ui/src/widgets/TextInputWidget.js')
-rw-r--r-- | vendor/oojs/oojs-ui/src/widgets/TextInputWidget.js | 535 |
1 files changed, 535 insertions, 0 deletions
diff --git a/vendor/oojs/oojs-ui/src/widgets/TextInputWidget.js b/vendor/oojs/oojs-ui/src/widgets/TextInputWidget.js new file mode 100644 index 00000000..1ba26641 --- /dev/null +++ b/vendor/oojs/oojs-ui/src/widgets/TextInputWidget.js @@ -0,0 +1,535 @@ +/** + * 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 = $( '<div>' ).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 ? $( '<textarea>' ) : $( '<input type="' + config.type + '" />' ); +}; + +/** + * Check if the input supports multiple lines. + * + * @return {boolean} + */ +OO.ui.TextInputWidget.prototype.isMultiline = function () { + return !!this.multiline; +}; + +/** + * Check if the input automatically adjusts its size. + * + * @return {boolean} + */ +OO.ui.TextInputWidget.prototype.isAutosizing = function () { + return !!this.autosize; +}; + +/** + * Select the entire text of the input. + * + * @chainable + */ +OO.ui.TextInputWidget.prototype.select = function () { + this.$input.select(); + return this; +}; + +/** + * Set the validation pattern. + * + * The validation pattern is either a regular expression, a function, or the 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). + * + * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name + * of a pattern (either ‘integer’ or ‘non-empty’) defined by the class. + */ +OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) { + if ( validate instanceof RegExp || validate instanceof Function ) { + this.validate = validate; + } else { + this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/; + } +}; + +/** + * Sets the 'invalid' flag appropriately. + * + * @param {boolean} [isValid] Optionally override validation result + */ +OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) { + var widget = this, + setFlag = function ( valid ) { + if ( !valid ) { + widget.$input.attr( 'aria-invalid', 'true' ); + } else { + widget.$input.removeAttr( 'aria-invalid' ); + } + widget.setFlags( { invalid: !valid } ); + }; + + if ( isValid !== undefined ) { + setFlag( isValid ); + } else { + this.isValid().done( setFlag ); + } +}; + +/** + * Check if a value is valid. + * + * This method returns a promise that resolves with a boolean `true` if the current value is + * considered valid according to the supplied {@link #validate validation pattern}. + * + * @return {jQuery.Promise} A promise that resolves to a boolean `true` if the value is valid. + */ +OO.ui.TextInputWidget.prototype.isValid = function () { + if ( this.validate instanceof Function ) { + var result = this.validate( this.getValue() ); + if ( $.isFunction( result.promise ) ) { + return result.promise(); + } else { + return $.Deferred().resolve( !!result ).promise(); + } + } else { + return $.Deferred().resolve( !!this.getValue().match( this.validate ) ).promise(); + } +}; + +/** + * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`. + * + * @param {string} labelPosition Label position, 'before' or 'after' + * @chainable + */ +OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) { + this.labelPosition = labelPosition; + this.updatePosition(); + return this; +}; + +/** + * Deprecated alias of #setLabelPosition + * + * @deprecated Use setLabelPosition instead. + */ +OO.ui.TextInputWidget.prototype.setPosition = + OO.ui.TextInputWidget.prototype.setLabelPosition; + +/** + * Update the position of the inline label. + * + * This method is called by #setLabelPosition, and can also be called on its own if + * something causes the label to be mispositioned. + * + * + * @chainable + */ +OO.ui.TextInputWidget.prototype.updatePosition = function () { + var after = this.labelPosition === 'after'; + + this.$element + .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after ) + .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after ); + + if ( this.label ) { + this.positionLabel(); + } + + return this; +}; + +/** + * Position the label by setting the correct padding on the input. + * + * @private + * @chainable + */ +OO.ui.TextInputWidget.prototype.positionLabel = function () { + // Clear old values + this.$input + // Clear old values if present + .css( { + 'padding-right': '', + 'padding-left': '' + } ); + + if ( this.label ) { + this.$element.append( this.$label ); + } else { + this.$label.detach(); + return; + } + + var after = this.labelPosition === 'after', + rtl = this.$element.css( 'direction' ) === 'rtl', + property = after === rtl ? 'padding-left' : 'padding-right'; + + this.$input.css( property, this.$label.outerWidth( true ) ); + + return this; +}; |