Localisation updates from https://translatewiki.net.
[mediawiki.git] / resources / src / jquery.lengthLimit.js
blobb4171514c23511a7182ad4ebfff34eabd36e2545
1 /**
2  * Limit string length.
3  *
4  * This module provides {@link jQuery} plugins that place different types of limits on strings.
5  * To use the plugins, load the module with {@link mw.loader}.
6  *
7  * For other methods for managing strings, see {@link module:mediawiki.String}.
8  *
9  * @example
10  * mw.loader.using( 'jquery.lengthLimit' ).then( () => {
11  *   // Create an input that only accepts values <= 4 bytes. For example: ðŸ’ªðŸ’ª is not a permitted value.
12  *   $( '<input type="text" value="💪">' ).byteLimit( 4 ).appendTo(document.body);
13  * } );
14  *
15  * @module jquery.lengthLimit
16  */
17 ( function () {
19         const
20                 eventKeys = [
21                         'keyup.lengthLimit',
22                         'keydown.lengthLimit',
23                         'change.lengthLimit',
24                         'mouseup.lengthLimit',
25                         'cut.lengthLimit',
26                         'paste.lengthLimit',
27                         'focus.lengthLimit',
28                         'blur.lengthLimit'
29                 ].join( ' ' ),
30                 trimByteLength = require( 'mediawiki.String' ).trimByteLength,
31                 trimCodePointLength = require( 'mediawiki.String' ).trimCodePointLength;
33         /**
34          * Utility function to trim down a string, based on byteLimit
35          * and given a safe start position.
36          *
37          * It supports insertion anywhere in the string, so "foo" to "fobaro" if
38          * limit is 4 will result in "fobo", not "foba". Basically emulating the
39          * native maxlength by reconstructing where the insertion occurred.
40          *
41          * @method '$.fn.trimByteLength'
42          * @memberof module:jquery.lengthLimit
43          * @deprecated Use {@link module:mediawiki.String.trimByteLength require( 'mediawiki.String' ).trimByteLength}
44          * instead.
45          * @static
46          * @param {string} safeVal Known value that was previously returned by this
47          * function, if none, pass empty string.
48          * @param {string} newVal New value that may have to be trimmed down.
49          * @param {number} byteLimit Number of bytes the value may be in size.
50          * @param {Function} [filterFunction] See jQuery#byteLimit.
51          * @return {module:mediawiki.String~StringTrimmed}
52          */
53         mw.log.deprecate( $, 'trimByteLength', trimByteLength,
54                 'Use require( \'mediawiki.String\' ).trimByteLength instead.', '$.trimByteLength' );
56         function lengthLimit( trimFn, limit, filterFunction ) {
57                 const allowNativeMaxlength = trimFn === trimByteLength;
59                 // If the first argument is the function,
60                 // set filterFunction to the first argument's value and ignore the second argument.
61                 if ( typeof limit === 'function' ) {
62                         filterFunction = limit;
63                         limit = undefined;
64                 // Either way, verify it is a function so we don't have to call
65                 // isFunction again after this.
66                 } else if ( !filterFunction || typeof filterFunction !== 'function' ) {
67                         filterFunction = undefined;
68                 }
70                 // The following is specific to each element in the collection.
71                 return this.each( ( i, el ) => {
72                         const $el = $( el );
74                         // If no limit was passed to lengthLimit(), use the maxlength value.
75                         // Can't re-use 'limit' variable because it's in the higher scope
76                         // that would affect the next each() iteration as well.
77                         // Note that we use attribute to read the value instead of property,
78                         // because in Chrome the maxLength property by default returns the
79                         // highest supported value (no indication that it is being enforced
80                         // by choice). We don't want to bind all of this for some ridiculously
81                         // high default number, unless it was explicitly set in the HTML.
82                         // Also cast to a (primitive) number (most commonly because the maxlength
83                         // attribute contains a string, but theoretically the limit parameter
84                         // could be something else as well).
85                         const elLimit = Number( limit === undefined ? $el.attr( 'maxlength' ) : limit );
87                         // If there is no (valid) limit passed or found in the property,
88                         // skip this. The < 0 check is required for Firefox, which returns
89                         // -1  (instead of undefined) for maxLength if it is not set.
90                         if ( !elLimit || elLimit < 0 ) {
91                                 return;
92                         }
94                         if ( filterFunction ) {
95                                 // Save function for reference
96                                 $el.data( 'lengthLimit.callback', filterFunction );
97                         }
99                         // Remove old event handlers (if there are any)
100                         $el.off( '.lengthLimit' );
102                         if ( filterFunction || !allowNativeMaxlength ) {
103                                 // Disable the native maxLength (if there is any), because it interferes
104                                 // with the (differently calculated) character/byte limit.
105                                 // Aside from being differently calculated,
106                                 // we also support a callback which can make it to allow longer
107                                 // values (e.g. count "Foo" from "User:Foo").
108                                 // maxLength is a strange property. Removing or setting the property to
109                                 // undefined directly doesn't work. Instead, it can only be unset internally
110                                 // by the browser when removing the associated attribute (Firefox/Chrome).
111                                 // https://bugs.chromium.org/p/chromium/issues/detail?id=136004
112                                 $el.removeAttr( 'maxlength' );
114                         } else {
115                                 // For $.byteLimit only, if we don't have a callback,
116                                 // the byteLimit can only be lower than the native maxLength limit
117                                 // (that is, there are no characters less than 1 byte in size). So lets (re-)enforce
118                                 // the native limit for efficiency when possible (it will make the while-loop below
119                                 // faster by there being less left to interate over). This does not work for $.codePointLimit
120                                 // (code units for surrogates represent half a character each).
121                                 $el.attr( 'maxlength', elLimit );
122                         }
124                         // Safe base value, used to determine the path between the previous state
125                         // and the state that triggered the event handler below - and enforce the
126                         // limit approppiately (e.g. don't chop from the end if text was inserted
127                         // at the beginning of the string).
128                         let prevSafeVal = '';
130                         // We need to listen to after the change has already happened because we've
131                         // learned that trying to guess the new value and canceling the event
132                         // accordingly doesn't work because the new value is not always as simple as:
133                         // oldValue + String.fromCharCode( e.which ); because of cut, paste, select-drag
134                         // replacements, and custom input methods and what not.
135                         // Even though we only trim input after it was changed (never prevent it), we do
136                         // listen on events that input text, because there are cases where the text has
137                         // changed while text is being entered and keyup/change will not be fired yet
138                         // (such as holding down a single key, fires keydown, and after each keydown,
139                         // we can trim the previous one).
140                         // See https://www.w3.org/TR/DOM-Level-3-Events/#events-keyboard-event-order for
141                         // the order and characteristics of the key events.
143                         function enforceLimit() {
144                                 const res = trimFn(
145                                         prevSafeVal,
146                                         this.value,
147                                         elLimit,
148                                         filterFunction
149                                 );
151                                 // Only set value property if it was trimmed, because whenever the
152                                 // value property is set, the browser needs to re-initiate the text context,
153                                 // which moves the cursor at the end the input, moving it away from wherever it was.
154                                 // This is a side-effect of limiting after the fact.
155                                 if ( res.trimmed === true ) {
156                                         this.value = res.newVal;
157                                         // Trigger a 'change' event to let other scripts attached to this node know that the value
158                                         // was changed. This will also call ourselves again, but that's okay, it'll be a no-op.
159                                         $el.trigger( 'change' );
160                                 }
161                                 // Always adjust prevSafeVal to reflect the input value. Not doing this could cause
162                                 // trimFn to compare the new value to an empty string instead of the
163                                 // old value, resulting in trimming always from the end (T42850).
164                                 prevSafeVal = res.newVal;
165                         }
167                         $el.on( eventKeys, function ( e ) {
168                                 if ( e.type === 'cut' || e.type === 'paste' ) {
169                                         // For 'cut'/'paste', the input value is only updated after the event handlers resolve.
170                                         setTimeout( enforceLimit.bind( this ) );
171                                 } else {
172                                         enforceLimit.call( this );
173                                 }
174                         } );
175                 } );
176         }
178         /**
179          * Enforces a byte limit on an input field, assuming UTF-8 encoding, for situations
180          * when, for example, a database field has a byte limit rather than a character limit.
181          * Plugin rationale: Browser has native maxlength for number of characters (technically,
182          * UTF-16 code units), this plugin exists to limit number of bytes instead.
183          *
184          * Can be called with a custom limit (to use that limit instead of the maxlength attribute
185          * value), a filter function (in case the limit should apply to something other than the
186          * exact input value), or both. Order of parameters is important!
187          *
188          * @memberof module:jquery.lengthLimit
189          * @param {number} [limit] Limit to enforce, fallsback to maxLength-attribute,
190          *  called with fetched value as argument.
191          * @param {Function} [filterFunction] Function to call on the string before assessing the length.
192          * @return {jQuery}
193          * @chainable
194          */
195         $.fn.byteLimit = function ( limit, filterFunction ) {
196                 return lengthLimit.call( this, trimByteLength, limit, filterFunction );
197         };
199         /**
200          * Enforces a codepoint (character) limit on an input field.
201          *
202          * For unfortunate historical reasons, browsers' native maxlength counts [the number of UTF-16
203          * code units rather than Unicode codepoints][1], which means that codepoints outside the Basic
204          * Multilingual Plane (such as many emojis) count as 2 characters each. This plugin exists to
205          * correct this.
206          *
207          * [1]: https://www.w3.org/TR/html5/sec-forms.html#limiting-user-input-length-the-maxlength-attribute
208          *
209          * Can be called with a custom limit (to use that limit instead of the maxlength attribute
210          * value), a filter function (in case the limit should apply to something other than the
211          * exact input value), or both. Order of parameters is important!
212          *
213          * @memberof module:jquery.lengthLimit
214          * @param {number} [limit] Limit to enforce, fallsback to maxLength-attribute,
215          *  called with fetched value as argument.
216          * @param {Function} [filterFunction] Function to call on the string before assessing the length.
217          * @return {jQuery}
218          * @chainable
219          */
220         $.fn.codePointLimit = function ( limit, filterFunction ) {
221                 return lengthLimit.call( this, trimCodePointLength, limit, filterFunction );
222         };
224 }() );