4
\$\begingroup\$

I have been looking for a Cross-browser solution for DOMTokenList and element.classList. I wasn't able to find much for DOMTokenList and the polyfills that I found for element.classList were mostly limited to IE8+ as they relied on modifying the DOM Element prototype. There are of course some other stand alone functions/methods that perform element.classList, as can be found in Change an element's CSS class with JavaScript. Anyway, I thought I'd create my own solution and as generically as possible. The code that follows is working, but unfortunately I am unable to test on any Microsoft browsers. So, I'm looking for any edge cases where the code may fail or suggestions for improvement, before I make it publicly available on GitHub or GIST (probably GIST would be best for such a project?).

Javascript

/*jslint maxerr: 50, indent: 4, browser: true, bitwise: true, white: true */

/* DOMTokenList v1.2
 *
 * home: http://code.google.com/p/domtokenlist//
 *
 * This type represents a set of space-separated tokens.
 * Commonly returned by HTMLElement.classList, HTMLLinkElement.relList,
 * HTMLAnchorElement.relList or HTMLAreaElement.relList.
 * It is indexed beginning with 0 as with JavaScript arrays.
 * DOMTokenList is always case-sensitive. Written with cross-browser compatibility in mind and
 * does not require any external libraries.
 */

var tokenList = (function (undef) {
    "use strict";

    /* The space characters, for the purposes of this specification,
     * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab), U+000A LINE FEED (LF),
     * U+000C FORM FEED (FF), and U+000D CARRIAGE RETURN (CR).
     *
     * The White_Space characters are those that have the Unicode property "White_Space"
     * in the Unicode PropList.txt data file.
     *
     * http://www.whatwg.org/specs/web-apps/current-work/multipage/common-microsyntaxes.html#space-character
     */
    var whiteSpaces = " \\t\\n\\f\\r",
        wsSplitRX = new RegExp("[" + whiteSpaces + "]+"),
        wsTrimRX = new RegExp("^[" + whiteSpaces + "]+|[" + whiteSpaces + "]+$", "g"),
        CtrBoolean = true.constructor,
        CtrNumber = (0).constructor,
        CtrString = "".constructor,
        toStringFn = {}.toString;

    function isValid(object) {
        return object !== undef && object !== null;
    }

    function toObject(object) {
        if (!isValid(object)) {
            throw new TypeError("Cannot convert " + object + " to object");
        }

        switch (typeof object) {
        case "boolean":
            return new CtrBoolean(object);
        case "number":
            return new CtrNumber(object);
        case "string":
            return new CtrString(object);
        default:
        }

        return object;
    }

    function toString(string) {
        if (!isValid(string)) {
            string = toStringFn.call(string);
        }

        return toObject(string).toString().replace(wsTrimRX, "");
    }

    function errorIfEmpty(string) {
        if (!string) {
            throw new SyntaxError("Can not be an empty string.");
        }
    }

    function validTokenString(string) {
        errorIfEmpty(string);
        if (string.search(wsSplitRX) !== -1) {
            throw new SyntaxError("May not contain \"White_Space\" characters.");
        }

        return string;
    }

    function isValidIndex(index) {
        return (index >>> 0) === index && index <= 4294967294;
    }

    function isValidIndexRange(index) {
        if (!isValidIndex(index)) {
            throw new RangeError("Index is not of valid range.");
        }

        return index;
    }

    function isValidLength(length) {
        if ((length >>> 0) !== length || length > 4294967295) {
            throw new RangeError("Length is not of valid range.");
        }

        return length;
    }

    function push(array, object) {
        var index = isValidIndexRange(array.length);

        array[index] = object;
        index += 1;
        array.length = index;
    }

    function elementToString(element) {
        if (isValid(element)) {
            return element.toString();
        }

        return "";
    }

    function indexOf(array, searchElement) {
        var length = isValidLength(array.length),
            key = 0;

        while (key < length) {
            if (array.hasOwnProperty(key) && searchElement === array[key]) {
                return key;
            }

            key += 1;
        }

        return -1;
    }

    function augmentToken(token) {
        return validTokenString(toString(token));
    }

    /* This type represents a set of space-separated tokens.
     * Commonly returned by HTMLElement.classList, HTMLLinkElement.relList,
     * HTMLAnchorElement.relList or HTMLAreaElement.relList.
     * It is indexed beginning with 0 as with JavaScript arrays.
     * DOMTokenList is always case-sensitive.
     *
     * https://developer.mozilla.org/en-US/docs/Web/API/DOMTokenList?redirectlocale=en-US&redirectslug=DOM%2FDOMTokenList
     */
    function DOMTokenList(string) {
        if (string === DOMTokenList.prototype.inheritAPI) {
            return;
        }

        this.set(string);
    }

    DOMTokenList.prototype = {
        inheritAPI: {},

        set: function (string) {
            var array = toString(string).split(wsSplitRX),
                length = isValidLength(array.length),
                element,
                i;

            for (i in this) {
                if (this.hasOwnProperty(i) && isValidIndex(i)) {
                    delete this[i];
                }
            }

            i = 0;
            this.length = 0;
            while (i < length) {
                element = array[i];
                if (element) {
                    push(this, element);
                }

                i += 1;
            }
        },

        toString: function () {
            var length = isValidLength(this.length),
                result = elementToString(this[0]),
                k = 1;

            while (k < length) {
                result += (" " + elementToString(this[k]));
                k += 1;
            }

            return result;
        },

        /* Properties
         *
         * length (read-only integer)
         * for cross-browser purposes 'this.length' is read/write
         *
         */
        length: 0,

        /* Methods
         *
         * item ( idx ) - returns an item in the list by its index (or undefined if the number is greater than or equal to the length of the list, prior to Gecko 7.0 returned null)
         * contains ( token ) - return true if the underlying string contains token, otherwise false
         * add ( token ) - adds token to the underlying string
         * remove ( token ) - remove token from the underlying string
         * toggle ( token ) - removes token from string and returns false. If token doesn't exist it's added and the function returns true
         *
         */
        item: function item(position) {
            if (this.hasOwnProperty(position) && isValidIndex(position)) {
                return this[position];
            }

            return undef;
        },

        contains: function (token) {
            return indexOf(this, augmentToken(token)) !== -1;
        },

        add: function (token) {
            token = augmentToken(token);
            if (!this.contains(token)) {
                push(this, token);
            }
        },

        remove: function (token) {
            token = augmentToken(token);
            if (this.contains(token)) {
                var index = indexOf(this, token),
                    length,
                    k,
                    from;

                while (index > -1) {
                    length = this.length;
                    this.length -= 1;
                    if (index < length) {
                        k = index;
                    } else {
                        k = length;
                    }

                    while (k < this.length) {
                        from = (k + 1);
                        if (this.hasOwnProperty(from)) {
                            this[k] = this[from];
                        } else {
                            delete this[k];
                        }

                        k += 1;
                    }

                    k = length;
                    while (k > this.length) {
                        delete this[k - 1];
                        k -= 1;
                    }

                    index = indexOf(this, token);
                }
            }
        },

        toggle: function (token) {
            token = augmentToken(token);
            if (this.contains(token)) {
                this.remove(token);

                return false;
            }

            this.add(token);

            return true;
        }
    };

    function TokenList(object, property) {
        if (object === DOMTokenList.prototype.inheritAPI) {
            return;
        }

        this.setProperty = function () {
            object[property] = DOMTokenList.prototype.toString.call(this);
        };

        this.update = function () {
            DOMTokenList.prototype.set.call(this, object[property]);
        };

        this.update();
    }

    TokenList.prototype = (function () {
        var prototype = new DOMTokenList(DOMTokenList.prototype.inheritAPI),
            overwrite = {
                toString: function () {
                    this.update();

                    return DOMTokenList.prototype.toString.call(this);
                },

                item: function (position) {
                    this.update();

                    return DOMTokenList.prototype.item.call(this, position);
                },

                contains: function (string) {
                    this.update();

                    return DOMTokenList.prototype.contains.call(this, string);
                },

                add: function ( /* token1, ..., tokenN */ ) {
                    var length = arguments.length,
                        i = 0;

                    this.update();
                    while (i < length) {
                        DOMTokenList.prototype.add.call(this, arguments[i]);
                        this.setProperty();
                        i += 1;
                    }
                },

                remove: function ( /* token1, ..., tokenN */ ) {
                    var length = arguments.length,
                        i = 0;

                    this.update();
                    while (i < length) {
                        DOMTokenList.prototype.remove.call(this, arguments[i]);
                        this.setProperty();
                        i += 1;
                    }
                },

                toggle: function (token) {
                    this.update();

                    var state = DOMTokenList.prototype.toggle.call(this, token);

                    this.setProperty();

                    return state;
                }
            },
            i;

        for (i in overwrite) {
            if (overwrite.hasOwnProperty(i)) {
                prototype[i] = overwrite[i];
            }
        }

        return prototype;
    }());

    function addTokenList(object, property, name) {
        object = toObject(object);
        property = toString(property);
        name = toString(name);
        errorIfEmpty(property);
        errorIfEmpty(name);
        if (object.hasOwnProperty(name)) {
            throw new Error("object already has a property named \"" + name + "\"");
        }

        object[name] = new TokenList(object, property);
    }

    return {
        "DOMTokenList": DOMTokenList,
        "TokenList": TokenList,
        "addTokenList": addTokenList
    };
}());

I have also a jsfiddle that demonstrates it in use on a DOM Element and on a plain Object.

HTML

<div id="test" class="test1 ok ok test2 test3">test</test>

Javascript

tokenList.addTokenList(test, "className", "testClassList");
tokenList.addTokenList(test2, "myTokenString", "myTokenList");
test.classList.remove("ok");
test.testClassList.update();
console.log(test.testClassList);
console.log(test.className);
test.testClassList.add("test4", "test5");
test.testClassList.remove("test2", "test3");
console.log(test.testClassList.toggle("test1"));
console.log(test.testClassList.toggle("test6"));
test.testClassList.remove("test6", "test7");
console.log(test.className);
console.log(test.testClassList.item(1));
console.log(test.testClassList, test2.myTokenList);
test2.myTokenList.toggle("ok");
test2.myTokenList.toggle("ok2");
console.log(test2);
console.log(test2.myTokenList);

I have also created a jsperf to compare the native classList of modern browsers vs this solution vs jquery.

Note: I added the jquery tests just for a comparison, though you can not really compare this solution and that of jquery as this solution tries to fully emulate DOMTokenList and provides greater flexibility and generality in POJS and doesn't harness any modern methods. The same code path executes on all browsers.

Update: I have now created a project on Google Code and have updated my more-or-less project to use it. A demonstration of which can be found on jsfiddle.

\$\endgroup\$
4
\$\begingroup\$

From top to bottom, my 2 cents:

function isValid(object) {
  return object !== undef && object !== null;
}

object parameter should be more aptly named ( value? ) since you will check non objects with this that are valid.

if (!isValid(string)) {
  string = toStringFn.call(string);
}

Seems wrong, why not

if( typeof string === "function" ){
  string = toStringFn.call(string);
}

You error out in both errorIfEmpty and validTokenString, but only the first function mentions that in the function name. I would expect then that validTokenString would not throw an error, and that it would return a boolean, not the the string I passed.

4294967294 is magic number, you should at least comment how you got there

Same goes for isValidIndexRange, functions with isXXX should return booleans, and not throw errors.

4294967295 is a magic number as well. isValidLength is not consistent with they way you wrote isValidIndex and isValidIndexRange.

function push() is odd, why do you not use the built-in push of Array after you check the validIndexRange? If there is a difference, you should comment it.

for indexOf, I would first check whether the browser has it built in, and use that if possible. Also consider something like

function indexOf(array, searchElement) {
    var pointer = array.length;

    while (pointer--) {
        if (searchElement === array[pointer]) {
            return pointer;
        }
    }
    return -1;
}

Personally, for short functions, I tend to drop the curlies:

function indexOf(array, searchElement)
{
    var pointer = array.length;

    while (pointer--)
      if (searchElement === array[pointer])
        return pointer;

    return -1;
}

Trinary could help for elementToString

function elementToString(element) {
    return isValid(element)?element.toString():""
}

it could also help for determining k k = (index < length)?index:length;

For loops could also make your code easier to parse, less sprinkled with housekeeping.

var i = 0;
this.update();
while (i < length) {
  DOMTokenList.prototype.remove.call(this, arguments[i]);
  this.setProperty();
  i += 1;
}

could be

this.update();
for (var i  = 0 ; i < length ;i++) {
  DOMTokenList.prototype.remove.call(this, arguments[i]);
  this.setProperty();
}

Having to call this.update(); in every function of TokenList is a code smell to me, I am not sure what to recommend instead, but it sure does not look good.

Finally, I think this could a tad more commenting ;)

|improve this answer|||||
\$\endgroup\$
  • \$\begingroup\$ Thanks or the feedback, I had noted some of those points myself but have been too busy to revisit and fix things up a little more. Magic numbers are javacript limits of number of entries in arrays (32 bit). Push was to overcome a bug in some older browser that I was reading about at the time. Converting the whiles to fors was something that I had thought about doing. At the time I was doing some testing on while loops and so that pattern was in my head. The reason for not polyfilling some routines was so that all browsers use the same code path. \$\endgroup\$ – Xotic750 Dec 4 '13 at 11:40
  • \$\begingroup\$ I never drop curlys due to linting practices, I also never drop semicolons. Just a style thing. The this.Update was a necessaary evil, tried object watch and other event listeners but support was just not there cross browser, and all other solutions that I tried had there own problems. This was the best that I could come up with, and it still has it's own problems. Comments - well yeah :P \$\endgroup\$ – Xotic750 Dec 4 '13 at 11:42
  • \$\begingroup\$ I would remove the check, 4 billion DOM elements seems a tad excessive. \$\endgroup\$ – konijn Dec 5 '13 at 15:25

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.