wikimedia/mediawiki-core

View on GitHub
resources/src/jquery.lengthLimit.js

Summary

Maintainability
A
1 hr
Test Coverage
/**
 * Limit string length.
 *
 * This module provides {@link jQuery} plugins that place different types of limits on strings.
 * To use the plugins, load the module with {@link mw.loader}.
 *
 * For other methods for managing strings, see {@link module:mediawiki.String}.
 *
 * @example
 * mw.loader.using( 'jquery.lengthLimit' ).then( () => {
 *   // Create an input that only accepts values <= 4 bytes. For example: 💪💪 is not a permitted value.
 *   $( '<input type="text" value="💪">' ).byteLimit( 4 ).appendTo(document.body);
 * } );
 *
 * @module jquery.lengthLimit
 */
( function () {

    const
        eventKeys = [
            'keyup.lengthLimit',
            'keydown.lengthLimit',
            'change.lengthLimit',
            'mouseup.lengthLimit',
            'cut.lengthLimit',
            'paste.lengthLimit',
            'focus.lengthLimit',
            'blur.lengthLimit'
        ].join( ' ' ),
        trimByteLength = require( 'mediawiki.String' ).trimByteLength,
        trimCodePointLength = require( 'mediawiki.String' ).trimCodePointLength;

    /**
     * Utility function to trim down a string, based on byteLimit
     * and given a safe start position.
     *
     * It supports insertion anywhere in the string, so "foo" to "fobaro" if
     * limit is 4 will result in "fobo", not "foba". Basically emulating the
     * native maxlength by reconstructing where the insertion occurred.
     *
     * @method '$.fn.trimByteLength'
     * @memberof module:jquery.lengthLimit
     * @deprecated Use {@link module:mediawiki.String.trimByteLength require( 'mediawiki.String' ).trimByteLength}
     * instead.
     * @static
     * @param {string} safeVal Known value that was previously returned by this
     * function, if none, pass empty string.
     * @param {string} newVal New value that may have to be trimmed down.
     * @param {number} byteLimit Number of bytes the value may be in size.
     * @param {Function} [filterFunction] See jQuery#byteLimit.
     * @return {module:mediawiki.String~StringTrimmed}
     */
    mw.log.deprecate( $, 'trimByteLength', trimByteLength,
        'Use require( \'mediawiki.String\' ).trimByteLength instead.', '$.trimByteLength' );

    function lengthLimit( trimFn, limit, filterFunction ) {
        const allowNativeMaxlength = trimFn === trimByteLength;

        // If the first argument is the function,
        // set filterFunction to the first argument's value and ignore the second argument.
        if ( typeof limit === 'function' ) {
            filterFunction = limit;
            limit = undefined;
        // Either way, verify it is a function so we don't have to call
        // isFunction again after this.
        } else if ( !filterFunction || typeof filterFunction !== 'function' ) {
            filterFunction = undefined;
        }

        // The following is specific to each element in the collection.
        return this.each( ( i, el ) => {
            let $el, elLimit, prevSafeVal;

            $el = $( el );

            // If no limit was passed to lengthLimit(), use the maxlength value.
            // Can't re-use 'limit' variable because it's in the higher scope
            // that would affect the next each() iteration as well.
            // Note that we use attribute to read the value instead of property,
            // because in Chrome the maxLength property by default returns the
            // highest supported value (no indication that it is being enforced
            // by choice). We don't want to bind all of this for some ridiculously
            // high default number, unless it was explicitly set in the HTML.
            // Also cast to a (primitive) number (most commonly because the maxlength
            // attribute contains a string, but theoretically the limit parameter
            // could be something else as well).
            elLimit = Number( limit === undefined ? $el.attr( 'maxlength' ) : limit );

            // If there is no (valid) limit passed or found in the property,
            // skip this. The < 0 check is required for Firefox, which returns
            // -1  (instead of undefined) for maxLength if it is not set.
            if ( !elLimit || elLimit < 0 ) {
                return;
            }

            if ( filterFunction ) {
                // Save function for reference
                $el.data( 'lengthLimit.callback', filterFunction );
            }

            // Remove old event handlers (if there are any)
            $el.off( '.lengthLimit' );

            if ( filterFunction || !allowNativeMaxlength ) {
                // Disable the native maxLength (if there is any), because it interferes
                // with the (differently calculated) character/byte limit.
                // Aside from being differently calculated,
                // we also support a callback which can make it to allow longer
                // values (e.g. count "Foo" from "User:Foo").
                // maxLength is a strange property. Removing or setting the property to
                // undefined directly doesn't work. Instead, it can only be unset internally
                // by the browser when removing the associated attribute (Firefox/Chrome).
                // https://bugs.chromium.org/p/chromium/issues/detail?id=136004
                $el.removeAttr( 'maxlength' );

            } else {
                // For $.byteLimit only, if we don't have a callback,
                // the byteLimit can only be lower than the native maxLength limit
                // (that is, there are no characters less than 1 byte in size). So lets (re-)enforce
                // the native limit for efficiency when possible (it will make the while-loop below
                // faster by there being less left to interate over). This does not work for $.codePointLimit
                // (code units for surrogates represent half a character each).
                $el.attr( 'maxlength', elLimit );
            }

            // Safe base value, used to determine the path between the previous state
            // and the state that triggered the event handler below - and enforce the
            // limit approppiately (e.g. don't chop from the end if text was inserted
            // at the beginning of the string).
            prevSafeVal = '';

            // We need to listen to after the change has already happened because we've
            // learned that trying to guess the new value and canceling the event
            // accordingly doesn't work because the new value is not always as simple as:
            // oldValue + String.fromCharCode( e.which ); because of cut, paste, select-drag
            // replacements, and custom input methods and what not.
            // Even though we only trim input after it was changed (never prevent it), we do
            // listen on events that input text, because there are cases where the text has
            // changed while text is being entered and keyup/change will not be fired yet
            // (such as holding down a single key, fires keydown, and after each keydown,
            // we can trim the previous one).
            // See https://www.w3.org/TR/DOM-Level-3-Events/#events-keyboard-event-order for
            // the order and characteristics of the key events.

            function enforceLimit() {
                const res = trimFn(
                    prevSafeVal,
                    this.value,
                    elLimit,
                    filterFunction
                );

                // Only set value property if it was trimmed, because whenever the
                // value property is set, the browser needs to re-initiate the text context,
                // which moves the cursor at the end the input, moving it away from wherever it was.
                // This is a side-effect of limiting after the fact.
                if ( res.trimmed === true ) {
                    this.value = res.newVal;
                    // Trigger a 'change' event to let other scripts attached to this node know that the value
                    // was changed. This will also call ourselves again, but that's okay, it'll be a no-op.
                    $el.trigger( 'change' );
                }
                // Always adjust prevSafeVal to reflect the input value. Not doing this could cause
                // trimFn to compare the new value to an empty string instead of the
                // old value, resulting in trimming always from the end (T42850).
                prevSafeVal = res.newVal;
            }

            $el.on( eventKeys, function ( e ) {
                if ( e.type === 'cut' || e.type === 'paste' ) {
                    // For 'cut'/'paste', the input value is only updated after the event handlers resolve.
                    setTimeout( enforceLimit.bind( this ) );
                } else {
                    enforceLimit.call( this );
                }
            } );
        } );
    }

    /**
     * Enforces a byte limit on an input field, assuming UTF-8 encoding, for situations
     * when, for example, a database field has a byte limit rather than a character limit.
     * Plugin rationale: Browser has native maxlength for number of characters (technically,
     * UTF-16 code units), this plugin exists to limit number of bytes instead.
     *
     * Can be called with a custom limit (to use that limit instead of the maxlength attribute
     * value), a filter function (in case the limit should apply to something other than the
     * exact input value), or both. Order of parameters is important!
     *
     * @memberof module:jquery.lengthLimit
     * @param {number} [limit] Limit to enforce, fallsback to maxLength-attribute,
     *  called with fetched value as argument.
     * @param {Function} [filterFunction] Function to call on the string before assessing the length.
     * @return {jQuery}
     * @chainable
     */
    $.fn.byteLimit = function ( limit, filterFunction ) {
        return lengthLimit.call( this, trimByteLength, limit, filterFunction );
    };

    /**
     * Enforces a codepoint (character) limit on an input field.
     *
     * For unfortunate historical reasons, browsers' native maxlength counts [the number of UTF-16
     * code units rather than Unicode codepoints][1], which means that codepoints outside the Basic
     * Multilingual Plane (such as many emojis) count as 2 characters each. This plugin exists to
     * correct this.
     *
     * [1]: https://www.w3.org/TR/html5/sec-forms.html#limiting-user-input-length-the-maxlength-attribute
     *
     * Can be called with a custom limit (to use that limit instead of the maxlength attribute
     * value), a filter function (in case the limit should apply to something other than the
     * exact input value), or both. Order of parameters is important!
     *
     * @memberof module:jquery.lengthLimit
     * @param {number} [limit] Limit to enforce, fallsback to maxLength-attribute,
     *  called with fetched value as argument.
     * @param {Function} [filterFunction] Function to call on the string before assessing the length.
     * @return {jQuery}
     * @chainable
     */
    $.fn.codePointLimit = function ( limit, filterFunction ) {
        return lengthLimit.call( this, trimCodePointLength, limit, filterFunction );
    };

}() );