Constructing a DOMTokenList/DOMSettableTokenList instance
Asked Answered
I

3

35

The DOMTokenList and DOMSettableTokenList interfaces (MDN, WHATWG) provide methods for manipulating ordered sets of string tokens represented by space-delimited strings. They are most commonly used in the form of the Element.prototype.classList property, a DOMTokenList which reflects the class attribute of an associated element.

var div = document.createElement('div');
div.setAttribute('class', 'hello world goodnight moon');

var list = div.classList;

console.assert(list.length           === 4);
console.assert(list[0]               === 'hello');
console.assert(list.item(1)          === 'world');
console.assert(list.contains('moon') === true);
console.assert(list.contains('mars') === false);

list.remove('world', 'earth', 'dirt', 'sand');
list.add('hello', 'mars');
list.toggle('goodnight');

console.assert(div.getAttribute('class') === 'hello moon mars');

I'm working on a custom element (HTML5Rocks, W3C Draft) which displays a real-time feed of the activity of specified Stack Overflow users. This list of users is specified in an ids attribute, and may be updated at any time.

<so-users ids="1114 22656 106224"></so-users>
document.querySelector('so-users').setAttribute('ids', '23354 115866');

Instead of requiring users to manipulate this attribute directly, I would like to have an .ids property providing a DOMTokenList that they can use instead. Ideally this would be directly associated with the attribute, but an unbound DOMSettableTokenList instance that I have to manually bind would also be fine.

document.querySelector('so-users').ids.add('17174');

Unfortunately, I have been unable to find any way to create a DOMTokenList instance. The definition is not a constructor, and directly creating an object using its prototype results in errors when I call any associated methods:

new DOMTokenList;         // TypeError: Illegal constructor
new DOMSettableTokenList; // TypeError: Illegal constructor
var list = Object.create(DOMSettableTokenList.prototype, {
  value: { value: 'hello world' }
});
console.assert(list instanceof DOMTokenList);
console.assert(list instanceof DOMSettableTokenList);
list.item(0); // TypeError: Illegal invocation
function TokenListConstructor() {
  this.value = 'hello world';
}
TokenListConstructor.prototype = DOMSettableTokenList.prototype;
var list = new TokenListConstructor;
console.assert(list instanceof DOMTokenList);
console.assert(list instanceof DOMSettableTokenList);
list.add('moon'); // TypeError: Illegal invocation

How can I construct a new DOMTokenList or DOMSettableTokenList instance?

Insanitary answered 20/3, 2015 at 17:30 Comment(8)
I did a quick search through Chrome and Firefox's sources, and I didn't see any way to do this without creating a new bound element each time you want a new instance. Maybe there's some clever hack possible that I missed, or will be one day.Insanitary
I don't think it's possible, see Creating instances of predefined objects in Javascript (and follow linked questions). You'd probably have to use a custom implementation, which also allows you to react to method calls.Showmanship
@JeremyBanks developer.mozilla.org/en-US/docs/Web/API/Element/… ?Vitrify
@JeremyBanks What is reason for utilizing ids attribute instead of class attribute ?Vitrify
@Vitrify I guess you're right, I could just use the polyfill's implementation, and it would be fine. My gut would prefer to use the native implementation if possible, though.Insanitary
@Vitrify Using the class attribute for these purpose would conflict with using it for styles. If I specified style classes, then a user could no longer iterate over the tokenlist to find all of the users it's targeting. I'd like to provide a cleaner API for my element by using a distinct attribute, like I generally would when creating custom elements.Insanitary
@JeremyBanks Interesting question. Tried examples at HTML5Rocks article , particularly html5rocks.com/en/tutorials/webcomponents/customelements/… , your desired approach is perhaps possible , with a few adjustments . An alternative approach could be to utilize element.dataset - defining add, remove, contains functions as to element.dataset - instead of attempting to incorporate DOMTokenList into the implementation. Are the main functions required and attached to the ids attribute add, contains, remove ?Vitrify
can you map ids to an array? then you can use push/pop/indexOf/etc, which aside from toggle has the same features, and more. or a Set?Toponym
B
6

You cannot create an DOMTokenList or an DOMSettableTokenList directly. Instead you should use the class attribute to store and retrieve your data and perhaps map an ids attribute of your DOM element to the classList property.

    var element = document.querySelector('so-users');
    element.ids = element.classList;

You can use relList according to the documentation but classList is more supported, the only drawback is that you might run into issues if one of your ids matches a class name so set an inline style to hide the element just in case.

For a custom component compatibility should be a concern (classList is present in IE>=10, Firefox 3.6, Chrome 8, Opera 11.5 and Safari 5.1, see http://caniuse.com/#feat=classlist) so if compatibility is in your requirements use the another solution posted below.

If you cannot use clases or classList and/or must use the ids attribute you should implement a custom function according to the spec with the following properties as functions.

  • item()
  • contains()
  • add()
  • remove()
  • toggle()

This is an example implementation of such functionality.

var TokenList = function (ids) {
    'use strict';
    var idsArray = [],
        self = this,
        parse = function (id, functionName, cb) {
            var search = id.toString();
            if (search.split(' ').length > 1) {
                throw new Error("Failed to execute '" + functionName + "' on 'TokenList': The token provided ('" + search + "') contains HTML space characters, which are not valid in tokens.');");
            } else {
                cb(search);
            }
        };

    function triggerAttributeChange() {
        if (self.tokenChanged && typeof self.tokenChanged === 'function') {
            self.tokenChanged(idsArray.toString());
        }
    }

    if (ids && typeof ids === 'string') {
        idsArray = ids.split(' ');
    }
    self.item = function (index) {
        return idsArray[index];
    };

    self.contains = function (id) {
        parse(id, 'contains', function (search) {
            return idsArray.indexOf(search) !== -1;
        });
    };

    self.add = function (id) {
        parse(id, 'add', function (search) {
            if (idsArray.indexOf(search) === -1) {
                idsArray.push(search);
            }
            triggerAttributeChange();
        });
    };

    self.remove = function (id) {
        parse(id, 'remove', function (search) {
            idsArray = idsArray.filter(function (item) {
                return item !== id;
            });
            triggerAttributeChange();
        });
    };

    self.toggle = function (id) {
        parse(id, 'toggle', function (search) {
            if (!self.contains(search)) {
                self.add(search);
            } else {
                self.remove(search);
            }
        });
    };

    self.tokenChanged = null;

    self.toString = function () {
        var tokens = '',
            i;
        if (idsArray.length > 0) {
            for (i = 0; i < idsArray.length; i = i + 1) {
                tokens = tokens + idsArray[i] + ' ';
            }
            tokens = tokens.slice(0, tokens.length - 1);
        }
        return tokens;
    };
};

Set an 'ids' property in your element with a new instance of this function and finally you must bound the targeted attribute to the property listening to changes to the element and updating the property o viceversa. You can do that with a mutation observer.

See firing event on DOM attribute change and https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver

var attachTokenList = function (element, prop, initialValues) {
    'use strict';
    var initValues = initialValues || element.getAttribute(prop),
        MutationObserver = window.MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
        observer,
        config,
        cancelMutation = false;

    function createTokenList(values) {
        var tList = new TokenList(values);
        tList.tokenChanged = function () {
            element.setAttribute(prop, element[prop].toString());
            cancelMutation = true;
        };
        element[prop] = tList;
    }

    createTokenList(initValues);

    observer = new MutationObserver(function (mutation) {
        var i,
            mutationrec,
            newAttr;
        if (mutation.length > 0 && !cancelMutation) {
            for (i = 0; i < mutation.length; i = i + 1) {
                mutationrec = mutation[i];
                if (mutationrec.attributeName === prop && element[prop]) {
                    newAttr = element.getAttribute(prop);
                    createTokenList(newAttr);
                }
            }
        }
        cancelMutation = false;
    });

    config = {
        attributes: true
    };
    observer.observe(element, config);
};

Testing to see if it works

<so-users ids="1234 5678"></so-users>
<button onclick="clickButton1()">Add 7890</button>
<button onclick="clickButton2()">Set to 3456</button>
<button onclick="clickButton3()">Add 9876</button>

Inside a script tag

var elem = document.querySelector('so-users');
attachTokenList(elem, 'ids')

function clickButton1 () {
    elem.ids.add('7890');
}

function clickButton2 () {
    elem.setAttribute('ids', '3456');
}

function clickButton3 () {
    elem.ids.add('9876');
}

Clicking the buttons in sequence set the ids attribute to '3456 9876'

Bilodeau answered 15/4, 2015 at 16:50 Comment(0)
C
2

You can get an instance of DOMTokenList with this function:

function newDOMTokenList(initialTokens) {
  const tmp = document.createElement(`div`);

  const classList = tmp.classList;
  if (initialTokens) {
    initialTokens.forEach(token => {
      classList.add(token);
    });
  }

  return classList;
}

We can 'steal' the DOMTokenList from a div, since it does not affect the current document until you choose to insert the element (for example by using insertAdjacentElement) and it will be garbage collected since we do not keep any references to the variable tmp.

Then you can use your list:

var list = newDOMTokenList(['a', 'b']);
list.add('c');
list.contains('d'); // false
list.contains('b'); // true
list.item(1) // 'b'
list instanceof DOMTokenList // true
// etc...

// render it to a string
var soUsers = document.querySelector('so-users');
soUsers.setAttribute('ids', list.toString());

You can even add a MutationObserver to the tmp element and get callbacks whenever the classList changes:

function newDOMTokenList(initialTokens, changed) {
  const tmp = document.createElement('div');

  const classList = tmp.classList;
  if (initialTokens) {
    initialTokens.forEach(token => {
      classList.add(token);
    });
  }

  if (changed) {
    const observer = new MutationObserver((mutationList, observer) => {
      for (const mutation of mutationList) {
        if (mutation.attributeName === 'class') {
          changed();
        }
      }
    });

    observer.observe(tmp, {attributes: true});
  }

  return classList;
}

This, however, will cause the tmp div to never be garbage collected, since the MutationObserver needs to keep a reference to it.

Cowled answered 17/1, 2023 at 12:54 Comment(0)
V
0

Utilizing Custom Elements - Adding JS properties and methods initialization approach , HTMLElement.dataset , try

var XFooProto = Object.create(HTMLElement.prototype);

// 1. Give x-foo a foo() method.
XFooProto.contains = function(id) {
  var data = JSON.parse(this.dataset.ids);
  return data.some(function(_id) {
    return id == _id
  })
};

XFooProto.add = function(id) {
  var data = JSON.parse(this.dataset.ids);
  if (!this.contains(id)) {
    data.push(id);
  };
  return data
};

XFooProto.remove = function(id) {
  var data = JSON.parse(this.dataset.ids);
  if (this.contains(id)) {
    for (var _id in data) {
      if (data[_id] === id) {
        data.splice(_id, 1)
      }
    };
  };
  return data
};

XFooProto.ids = function() {
  return this.dataset.ids
};

// 2. Define a property read-only "bar".
// Object.defineProperty(XFooProto, "ids", {value: this});

// 3. Register x-foo's definition.
var XFoo = document.registerElement('x-foo', {prototype: XFooProto});

// 4. Instantiate an x-foo.
var xfoo = document.createElement('x-foo');

xfoo.dataset.ids = '["23354", "115866"]';

// 5. Add it to the page.
document.body.appendChild(xfoo);

console.log(xfoo.add("123")); // `["23354", "115866", "123"]`

console.log(xfoo.remove("123")); // `["23354", "115866"]`

console.log(xfoo.contains("123")); // `false`

console.log(xfoo.contains("23354")); // `true`

console.log(xfoo.ids()); // `["23354", "115866"]` , type : `String`

var pre = document.getElementsByTagName("pre")[0]

pre.innerText = JSON.stringify(JSON.parse(xfoo.dataset.ids), null, 4);
<pre></pre>
Vitrify answered 15/4, 2015 at 17:52 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.