types/tokenlist.js

'use strict';

/**
 * @author schukai GmbH
 */

import {Monster} from '../namespace.js';
import {Base} from  './base.js';
import {isString, isIterable} from '../types/is.js';
import {validateString, validateFunction} from '../types/validate.js';

/**
 * A tokenlist allows you to manage tokens (individual character strings such as css classes in an attribute string).
 *
 * The tokenlist offers various functions to manipulate values. For example, you can add, remove or replace a class in a CSS list.
 *
 * you can call the method via the monster namespace `new Monster.Types.TokenList()`.
 *
 * ```
 * <script type="module">
 * import {Monster} from 'https://cdn.jsdelivr.net/npm/@schukai/monster@1.6.0/dist/modules/types/tokenlist.js';
 * console.log(new Monster.Types.TokenList("myclass row"))
 * console.log(new Monster.Types.TokenList("myclass row"))
 * </script>
 * ```
 *
 * Alternatively, you can also integrate this function individually.
 *
 * ```
 * <script type="module">
 * import {TokenList} from 'https://cdn.jsdelivr.net/npm/@schukai/monster@1.6.0/dist/modules/types/tokenlist.js';
 * console.log(new TokenList("myclass row"))
 * console.log(new TokenList("myclass row"))
 * </script>
 * ```
 *
 * This class implements the [iteration protocol](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols).
 *
 * ```
 * console.log(typeof new TokenList("myclass row")[Symbol.iterator]); // "function"
 * ```
 *
 *
 * @since 1.2.0
 * @copyright schukai GmbH
 * @memberOf Monster/Types
 */
class TokenList extends Base {

    /**
     *
     * @param {array|string|iteratable} init
     */
    constructor(init) {
        super();
        this.tokens = new Set();

        if (typeof init !== "undefined") {
            this.add(init);
        }

    }

    /**
     * Iterator protocol
     *
     * @returns {Symbol.iterator}
     */
    getIterator() {
        return this[Symbol.iterator]();
    }

    /**
     * Iterator
     *
     * @returns {{next: ((function(): ({value: *, done: boolean}))|*)}}
     */
    [Symbol.iterator]() {
        // Use a new index for each iterator. This makes multiple
        // iterations over the iterable safe for non-trivial cases,
        // such as use of break or nested looping over the same iterable.
        let index = 0;
        let entries = this.entries()

        return {
            next: () => {
                if (index < entries.length) {
                    return {value: entries?.[index++], done: false}
                } else {
                    return {done: true}
                }
            }
        }
    }

    /**
     * Returns true if it contains token, otherwise false
     *
     * ```
     * new TokenList("start middle end").contains('start')); // ↦ true
     * new TokenList("start middle end").contains('end')); // ↦ true
     * new TokenList("start middle end").contains('xyz')); // ↦ false
     * new TokenList("start middle end").contains(['end','start','middle'])); // ↦ true
     * new TokenList("start middle end").contains(['end','start','xyz'])); // ↦ false
     * ```
     *
     * @param {array|string|iteratable} value
     * @returns {boolean}
     */
    contains(value) {
        if (isString(value)) {
            value = value.trim()
            let counter = 0;
            value.split(" ").forEach(token => {
                if (this.tokens.has(token.trim()) === false) return false;
                counter++
            })
            return counter > 0 ? true : false;
        }

        if (isIterable(value)) {
            let counter = 0;
            for (let token of value) {
                validateString(token);
                if (this.tokens.has(token.trim()) === false) return false;
                counter++
            }
            return counter > 0 ? true : false;
        }

        return false;
    }

    /**
     * add tokens
     *
     * ```
     * new TokenList().add("abc xyz").toString(); // ↦ "abc xyz"
     * new TokenList().add(["abc","xyz"]).toString(); // ↦ "abc xyz"
     * new TokenList().add(undefined); // ↦ add nothing
     * ```
     *
     * @param {array|string|iteratable} value
     * @returns {TokenList}
     * @throws {TypeError} unsupported value
     */
    add(value) {
        if (isString(value)) {
            value.split(" ").forEach(token => {
                this.tokens.add(token.trim());
            })
        } else if (isIterable(value)) {
            for (let token of value) {
                validateString(token);
                this.tokens.add(token.trim());
            }
        } else if (typeof value !== "undefined") {
            throw new TypeError("unsupported value");
        }

        return this;
    }

    /**
     * remove all tokens
     *
     * @returns {TokenList}
     */
    clear() {
        this.tokens.clear();
        return this;
    }

    /**
     * Removes token
     *
     * ```
     * new TokenList("abc xyz").remove("xyz").toString(); // ↦ "abc"
     * new TokenList("abc xyz").remove(["xyz"]).toString(); // ↦ "abc"
     * new TokenList("abc xyz").remove(undefined); // ↦ remove nothing
     * ```
     *
     * @param {array|string|iteratable} value
     * @returns {TokenList}
     * @throws {TypeError} unsupported value
     */
    remove(value) {
        if (isString(value)) {
            value.split(" ").forEach(token => {
                this.tokens.delete(token.trim());
            })
        } else if (isIterable(value)) {
            for (let token of value) {
                validateString(token);
                this.tokens.delete(token.trim());
            }
        } else if (typeof value !== "undefined") {
            throw new TypeError("unsupported value");
        }

        return this;
    }

    /**
     * this method replaces a token with a new token.
     *
     * if the passed token exists, it is replaced with newToken and TokenList is returned.
     * if the token does not exist, newToken is not set and TokenList is returned.
     *
     * @param {string} token
     * @param {string} newToken
     * @returns {TokenList}
     */
    replace(token, newToken) {
        validateString(token);
        validateString(newToken);
        if (!this.contains(token)) {
            return this;
        }

        let a = Array.from(this.tokens)
        let i = a.indexOf(token);
        if (i === -1) return this;

        a.splice(i, 1, newToken);
        this.tokens = new Set();
        this.add(a);

        return this;


    }

    /**
     * Removes token from string. If token doesn't exist it's added.
     * 
     * ```
     * new TokenList("abc def ghi").toggle("def xyz").toString(); // ↦ "abc ghi xyz"
     * new TokenList("abc def ghi").toggle(["abc","xyz"]).toString(); // ↦ "def ghi xyz"
     * new TokenList().toggle(undefined); // ↦ nothing
     * ```
     * 
     * @param {array|string|iteratable} value
     * @returns {boolean}
     * @throws {TypeError} unsupported value
     */
    toggle(value) {

        if (isString(value)) {
            value.split(" ").forEach(token => {
                toggleValue.call(this, token);
            })
        } else if (isIterable(value)) {
            for (let token of value) {
                toggleValue.call(this, token);
            }
        } else if (typeof value !== "undefined") {
            throw new TypeError("unsupported value");
        }

        return this;

    }

    /**
     * returns an array with all tokens
     *
     * @returns {array}
     */
    entries() {
        return Array.from(this.tokens)
    }

    /**
     * executes the provided function with each value of the set
     *
     * @param {function} callback
     * @returns {TokenList}
     */
    forEach(callback) {
        validateFunction(callback);
        this.tokens.forEach(callback);
        return this;
    }

    /**
     * returns the individual tokens separated by a blank character
     *
     * @returns {string}
     */
    toString() {
        return this.entries().join(' ');
    }

}

/**
 * @private
 * @param token
 * @returns {toggleValue}
 * @throws {Error} must be called with TokenList.call
 */
function toggleValue(token) {
    if (!(this instanceof TokenList)) throw Error("must be called with TokenList.call")
    validateString(token);
    token = token.trim();
    if (this.contains(token)) {
        this.remove(token);
        return this;
    }
    this.add(token);
    return this;
}

Monster.assignToNamespace('Monster.Types', TokenList);
export {Monster, TokenList}