Source: form.js

import { Element } from "./element";
import { Label } from "./label";
import { Input } from "./input";
import { SimpleButton } from "./button";
import { Textarea } from "./textarea";
import { Div } from "./div";
import { Select } from "./select";

class Feedback extends Element {

    constructor(msg) {
        super("div");
        this.elem.innerText = msg;
    }
}

class Valid extends Feedback {

    constructor(msg) {
        super(msg);
        this.addClasses("valid-feedback");

    }
}

class Invalid extends Feedback {

    constructor(msg) {
        super(msg);
        this.addClasses("invalid-feedback");
    }
}

/**
 * Form item element.
 */
class FormItem extends Element {

    #row;
    #item;
    #customValidation;
    #invalid;
    #itemDiv;

    /**
     * 
     * @param {string} label 
     * @param {Element} item 
     */
    constructor(label, item) {
        super("div");
        this.#item = item;
        this.#itemDiv = new Div()
            .addClasses("col-8")
            .appendChild(this.#item);
        this.#row = new Div()
            .addClasses("row", "g-3", "align-items-center")
            .appendChild(new Div()
                .addClasses("col-4")
                .appendChild(new Label(label).addClasses("col-form-label")))
            .appendChild(this.#itemDiv);
        this.appendChild(this.#row);
        this.#customValidation = null;
        this.#invalid = null;
    }

    set customValidation(fun) {
        this.#customValidation = fun;
    }

    validate() {
        if (this.#customValidation) {
            const message = this.#customValidation(this.value);
            this.#item.elem.setCustomValidity(message ? message : "");
            if (message) {
                this.#invalid = new Invalid(message);
                this.#itemDiv.appendChild(this.#invalid);
            }
        }
        else {
            this.#item.elem.setCustomValidity("");
        }
    }

    clear() {
        if (this.#invalid) {
            this.#invalid.remove();
            this.#invalid = null;
        }
        return this;
    }

    /**
     * Gets custom validation.
     */
    get customValidation() {
        return this.#customValidation;
    }

    /**
     * Gets item.
     */
    get item() {
        return this.#item;
    }

    /**
     * Gets value.
     */
    get value() {
        return this.#item.value;
    }

    /**
     * Sets value.
     */
    set value(val) {
        this.#item.value = val;
    }

    /**
     * Sets as read-only.
     * @returns {FormItem} this
     */
    disable() {
        this.#item.disable();
        return this;
    }

    /**
     * Sets as editable field.
     * @returns {FormItem} this
     */
    enable() {
        this.#item.enable();
        return this;
    }

    /**
     * Replaces in Form this FormItem with the input FormItem.
     * @param {FormItem} formItem 
     * @returns {FormItem} this
     */
    replace(formItem) {
        super.replace(formItem).parent.items.push(formItem);
        return this;
    }
}

/**
 * Form input element.
 */
export class FormInput extends FormItem {

    /**
     * 
     * @param {string} label as HTML code
     * @param {string} type one of supported HTML input type
     * @param {string} placeholder 
     */
    constructor(label, type, placeholder) {
        super(label, new Input(type, placeholder).addClasses("form-control"));
    }
}

/**
 * Form textarea element.
 */
export class FormTextarea extends FormItem {

    /**
     * 
     * @param {string} label as HTML code
     * @param {string} placeholder 
     */
    constructor(label, placeholder) {
        super(label, new Textarea(placeholder, null, null).addClasses("form-control"));
    }
}

/**
 * Form select element.
 */
export class FormSelect extends FormItem {

    /**
     * 
     * @param {string} label as HTML code
     * @param {string[]} options 
     */
    constructor(label, options) {
        super(label, new Select(options));
    }

    /**
     * Binds a function called on change event.
     * @param {function} fun 
     * @returns {FormSelect} this
     */
    onChange(fun) {
        this.item.onChange(fun);
        return this;
    }
}

/**
 * Form element.
 */
export class Form extends Element {

    #items;
    #submitButton;
    #cancelButton;

    /**
     * 
     * @param {...FormItem} formItems 
     */
    constructor(...formItems) {
        super("form");
        this.#items = [];
        for (const formItem of formItems) {
            this.#items.push(formItem);
            this.appendChild(formItem);
        }
        this.elem.oninput = event => this.enableSubmit();
        this.elem.noValidate = true;
        this.addClasses("needs-validation");
    }

    /**
     * Gets items.
     */
    get items() {
        return this.#items;
    }

    /**
     * Binds a callback for the submit event.
     * @param {function} callback
     * @param {string} buttonContent submit button inner text
     * @param {string | null} buttonLoading the loading message displayed in button after click
     * @param {...string} buttonClasses submit button classes
     * @returns {Form} this
     */
    onSubmit(callback, buttonContent, buttonLoading, ...buttonClasses) {
        this.#submitButton = new SimpleButton("submit", null, buttonContent)
            .disable()
            .addClasses(...buttonClasses);
        this.appendChild(this.#submitButton);
        this.elem.addEventListener('submit', async event => {
            this.disableSubmit();
            const oldInnerHTML = this.#submitButton.runSpinner(buttonLoading);
            event.preventDefault();
            event.stopPropagation();
            this.#items.forEach(item => item.clear().validate());
            this.addClasses('was-validated');
            if (this.elem.checkValidity()) {
                await callback(this);
            }
            else {
                this.enableSubmit();
            }
            this.#submitButton.innerHTML = oldInnerHTML;
        }, false);
        return this;
    }

    /**
     * Binds a callback for the cancel event.
     * @param {function} callback 
     * @param {string} buttonContent cancel button inner text
     * @param {string | null} buttonLoading the loading message displayed in button after click
     * @param {...string} buttonClasses cancel button classes
     * @returns {Form} this
     */
    onCancel(callback, buttonContent, buttonLoading, ...buttonClasses) {
        this.appendChild(new SimpleButton("button", null, buttonContent)
            .enable()
            .addClasses(...buttonClasses)
            .onClick(button => callback(this), buttonLoading));
        return this;
    }

    /**
     * Disables form submission.
     * @returns {Form} this
     */
    disableSubmit() {
        this.#submitButton && this.#submitButton.disable();
        return this;
    }

    /**
     * Enables form submission.
     * @returns {Form} this
     */
    enableSubmit() {
        this.#submitButton && this.#submitButton.enable();
        return this;
    }

    /**
     * Disables cancel button.
     * @returns {Form} this
     */
    disableCancel() {
        this.#cancelButton && this.#cancelButton.disable();
        return this;
    }

    /**
     * Enables cancel button.
     * @returns {Form} this
     */
    enableCancel() {
        this.#cancelButton && this.#cancelButton.enable();
        return this;
    }

    /**
     * Disables items.
     * @returns {Form} this
     */
    disableItems() {
        for (const item of this.#items) {
            item.disable();
        }
        return this;
    }

    /**
     * Enables items.
     * @returns {Form} this
     */
    enableItems() {
        for (const item of this.#items) {
            item.enable();
        }
        return this;
    }
}