shawnholman/Aych

View on GitHub
src/elements/Element.ts

Summary

Maintainability
A
1 hr
Test Coverage
import {Attribute, Attributes, SimpleObject} from "../interfaces";
import {Renderable} from "../core/Renderable";
import {isAttributes, isString} from "../Util";
import {TemplateParser} from "../core/TemplateParser";

/** @ignore */
const CLASS_IDENTIFIER = '.';

/** @ignore */
const ID_IDENTIFIER = '#';

/**
 * The Element class is the base class for all elements. This class defines properties and attributes
 * that a fundamental for the creation of elements.
 */
export abstract class Element extends Renderable {
    private readonly tag: string;
    private attributes: Attributes = {};

    /**
     * Constructs an element
     * @param tag The tag name of the element.
     * @param tier1 Either a string representing the identifier string or a list of attributes.
     * @param tier2 A list of attributes, accepted ONLY IF tier1 is not an attribute.
     */
    constructor(tag: string, tier1?: string | Attributes, tier2?: Attributes) {
        super();

        // Assign the tag name. Make it lower case because HTML tags are typically lower cased.
        this.tag = tag.toLowerCase();

        if (isString(tier1) && Element.isIdentifierString(tier1)) {
            this.setIdentifierString(tier1);
        } else if (isAttributes(tier1)) {
            this.setAttributes(tier1);
        } else { // Tier 1 does not exist so we do not need to check further
            return;
        }

        if (!tier2) {
            return;
        }

        if (!isAttributes(tier1) && isAttributes(tier2)) {
            this.setAttributes(tier2);
        } else {
            throw new Error('Attributes field has been declared twice.');
        }
    }

    /** Get element tag */
    getTag(): string {
        return this.tag;
    }

    /** Get element id */
    getId(): string | null {
        if (!Object.prototype.hasOwnProperty.call(this.attributes, 'id')) {
            return null;
        }
        return this.attributes['id'] as string;
    }

    /** Get element class list */
    getClassList(): Array<string> {
        if (!Object.prototype.hasOwnProperty.call(this.attributes, 'class')) {
            return [];
        }
        return (this.attributes['class'] as string).split(" ");
    }

    /** Get attributes */
    getAttributes(): Attributes {
        return this.attributes;
    }

    /**
     * Set the ID.
     * @param id The new id.
     */
    setId(id: string): Element {
        this.setAttribute('id', id);
        return this;
    }

    /**
     * Sets the elements class list.
     * @param classes The new set of classes that the element will get.
     */
    setClassList(classes: Array<string>): Element {
        if (classes.length) {
            this.setAttribute('class', classes.join(" "));
        }
        return this;
    }

    /**
     * Set the identifiers.
     * @param identifier An identifier string. See isIdentifierString for more information.
     */
    setIdentifiers(identifier: string): Element {
        if (Element.isIdentifierString(identifier)) {
            this.setIdentifierString(identifier);
        } else { // We are going to excuse a non-valid identifier string for now.
            console.warn('Identifier string was not valid. Setter had no effect.');
        }
        return this;
    }

    /**
     * Sets the attributes
     * @param attributes
     */
    setAttributes(attributes: Attributes): Element {
        for (let attribute in attributes) {
            this.setAttribute(attribute, attributes[attribute]);
        }
        return this;
    }

    /**
     * Sets an attribute of the element
     * @param name Name of the attribute.
     * @param value The attribute value. Alternatively accepts an array with the form: [condition, string1, string2?]
     * which will use string1 if condition == trye. Otherwise it will use string2 if it exists. If string2 is not
     * defined and condition == false, then the attribute will be omitted.
     * TODO: Add support for TemplateParser.evaluate
     */
    setAttribute(name: string, value: Attribute): Element {
        // If the attribute is an array like: [true, 'trueAttr', 'falseAttr']
        // Then if true, use: 'trueAttr", else use: 'falseAttr'
        if (Array.isArray(value)) {
            // If the boolean element is true
            if (value[0]) { // We should use the second element
                value = value[1] as (string|null);
            } else if (value.length === 3) {
                // If the third element exists (and we already proved the boolean element is false) then
                // we should use the else text.
                value = value[2] as (string|null);
            } else {
                // If false without an else, then we do not include this attribute at all.
                return this;
            }
        }

        if (value !== null && name === 'class') {
            const hasClassAttribute = Object.prototype.hasOwnProperty.call(this.attributes, 'class');
            if (value.startsWith('+')) {
                if (!hasClassAttribute) {
                    this.attributes['class'] = value.substr(1);
                } else if (!(this.attributes['class'] as string).includes(value.substr(1))) {
                    this.attributes['class'] += " " + value.substr(1);
                }
                return this;
            } else if (value.startsWith('-')) {
                if (!hasClassAttribute) {
                    return this;
                }
                const replacer = new RegExp(" ?" + value.substr(1));
                this.attributes['class'] = (this.attributes['class'] as string)!.replace(replacer, "");
                return this;
            }
        }

        this.attributes[name] = value;

        return this;
    }

    /**
     * Determines if a string is an identifier string. An identifier string either starts with "#" or "." and specifies
     * a list of identifiers for an element. An example is "#book.col.col-xs-5". There can only be a single id and it
     * must be specified at the beginning. For example: ".col.col-xs-5#book" is not a valid identifier string.
     * @param tester
     */
    protected static isIdentifierString(tester: string): boolean {
        const string = tester.trim();

        // identifier string can't be empty and must start with either "." or "#"
        if (string.length === 0) {
            return false;
        }
        return string.match(/^(#[A-z-_][A-z0-9-_]*)?([.][A-z-_][A-z0-9-_]*)*$/) !== null;
    }

    /**
     * Set's the id and classes of the element given an identifier string.
     * @param identifier An identifier string. See isIdentifierString for more information.
     */
    protected setIdentifierString(identifier: string): void {
        const identifiers = identifier.trim().split(CLASS_IDENTIFIER);

        if (identifiers[0].startsWith(ID_IDENTIFIER)) {
            this.setId(identifiers!.shift()!.substr(1));
        } else {
            // Else, it starts with a "." which results in a beginning empty string
            // that we need to remove.
            identifiers.shift();
        }
        this.setClassList(identifiers);
    }

    /** Given an object of attributes, converts attributes into the HTML equivalent list. */
    protected getHtmlAttributeList(templates?: SimpleObject): string {
        const attributesEntries = Object.entries(this.getAttributes());
        const attributeString = attributesEntries.reduce((str, [name, value]) => {
            // A null value indicates that the attribute has no value.
            // An attribute like multiple on select would do this: <select multiple></select>
            if (value === null) {
                return str + ' ' + name;
            }
            return str + ` ${name}="${value}"`;
        }, '');

        return templates ? TemplateParser.template(attributeString, templates) : attributeString;
    }
}