Home Reference Source

js/template/Form/LabelGroup.js

import Template from '~/template/Template';
import ConstraintStateTemplate, { ConstraintState } from '~/template/Form/ConstraintStateTemplate';
import ActionControllerDelegate from '~/delegate/ActionControllerDelegate';
import { HandleUnhandledPromise } from '~/helpers/ErrorManager';
import SVG from '~/models/Request/SVG';
import Theme from '~/models/Theme';
import Random from '~/modern/Random';

import { of } from 'rxjs';
import { tap, shareReplay, share, skip, map, distinctUntilChanged } from 'rxjs/operators';

import tippy from 'tippy.js/dist/tippy.all.min.js';

/**
 * A label group is an input along with surrounding metadata. This means this
 * manages validation, interaction, labels, and more. This also allows you to
 * configure tooltips.
 *
 * The label group can mount any item that implements the {@link InputInterface}
 * interface.
 */
export default class LabelGroup extends Template {
    /**
     * A group of label and the input.
     *
     * If you do supply a template. Ensure it has `.input` attribute which
     * evaluates to the underling HTMLInputElement which can be observed for
     * value updates. See {@link InputInterface}
     *
     * @param {string} label - The label (self-explantory)
     * @param {InputInterface} input - The input to mount
     * @param {Object} o - additional options.
     * @param {?string} o.tooltip Some info describing what this does. Do note if
     *                            you select the horizontal style. This will appear
     *                            as an inline description.
     * @param {boolean} [o.isHorizontalStyle=false] - Uses 'horizontal' style. Places
     *                                              title and description on the side.
     *                                              used for things such as checkbox.
     * @param {?ButtonTemplate} o.button - Pass if you want to keep a button within label group for alignment purposes
     * @param {FormConstraint} [o.liveConstraint=null] - Contraints already setup to show
     * @param {?ForeignInteractor} [o.interactor=null] - Foreign interactor to link `{ foreignInteractor: ForeignInteractor, label: String }`
     * @param {boolean} [o.hideLabel=false] - If label should be hidden.
     * @param {?number} [o.weight=null] - If in group how much weight (default is one)
     */
    constructor(label, input, {
        tooltip = "",
        button = null,
        liveConstraint = null,
        interactor = null,
        isHorizontalStyle = false,
        hideLabel = false,
        weight = null
    } = {}) {
        const normalizedLabel = label.toLowerCase().replace(/[^a-z]/g, '');
        const id = `lg-${normalizedLabel}-${Random.ofLength(16)}`;
        const tooltipPlaceholder = tooltip && !isHorizontalStyle ? <span class="label-group__tooltip" title={tooltip}></span> : <DocumentFragment/>;

        const interactorNode = interactor ? (
            <span class="preview-wrap"><a href={ interactor.foreignInteractor.link } target="_blank">{ interactor.label }</a></span>
        ) : <DocumentFragment/>;

        let orientation = isHorizontalStyle ? 'horizontal' : 'vertical';
        let description = isHorizontalStyle ? <dd>{ tooltip }</dd> : <DocumentFragment/>;
        let style = isHorizontalStyle ? 'group' : 'clean';

        const root = (
            <div class={`item-wrap label-group label-group--style-${style} item-wrap--orientation-${orientation}`}>
                { !hideLabel ? (
                    <dl>
                        <dt><label for={id}>{ label }{" "}{ tooltipPlaceholder }{ interactorNode }</label></dt>
                        { description }
                    </dl>
                ) : <DocumentFragment/>  }
            </div>
        );

        // Input element
        const elem = isHorizontalStyle ? input.prependInContext(root) : input.loadInContext(root);

        super(root);

        /** @type {ActionControllerDelegate} */
        this.validationDelegate = new ActionControllerDelegate();

        const userInputTarget = input.userInput;
        if (userInputTarget) {
            userInputTarget.id = id;
        }

        // Observes value change
        this._observeValue = input.observeValue();

        // Live constraints
        this._constraints = [];
        if (liveConstraint) {
            for (const sourceValidator of liveConstraint._validators) {
                const template = new ConstraintStateTemplate(sourceValidator.error);
                template.loadInContext(root);

                this._constraints.push({
                    constraintValidator: sourceValidator,
                    template: template
                });
            }

            this._observeValidation = this._observeValue
                .pipe(
                    map(value => liveConstraint.validate(value)),
                    shareReplay());

            this._observeValidation
                .pipe(
                    skip(1))
                .subscribe(
                    errors => this.validate(errors))
        } else {
            this._observeValidation = of([])
        }

        // Load button
        button?.loadInContext(root);

        this._tooltipPlaceholder = tooltipPlaceholder;

        if (tooltip) {
            this.setTooltip(tooltip)
                .catch(HandleUnhandledPromise);
        }

        if (weight !== null) {
            this.weight = weight;
        }

        /** @type {TextInputTemplate} */
        this.input = input;

        this.defineLinkedClass('padTop', 'item-wrap--pad-top');
        this.defineLinkedClass('padHorizontal', '!item-wrap--nopad-horizontal');
    }

    /**
     * Sets the weight in a group
     * @type {number}
     */
    set weight(newWeight) {
        this.underlyingNode.style.flex = `${newWeight}`;
    }

    /**
     * Returns observer for the value.
     * @return {Observable}
     */
    observeValue() {
        return this._observeValue;
    }

    /**
     * Observe the validation status. This provides list of errors
     * @return {Observable}
     */
    observeValidation() {
        return this._observeValidation;
    }

    /**
     * Sets the value of the underlying input value if applicable. Gives no
     * guarantee of the sync with UI.
     * @type {any}
     */
    get value() { return this.input.userInput.value; }

    /**
     * Sets the value of the type. USE of this setter is NOT reccomended.
     * @param {any} newValue
     */
    set value(newValue) {
        this.input.userInput.value = newValue;
    }

    /**
     * Validates the LabelGroup for live labels
     * @param {ValidationError[]} errors
     */
    validate(errors) {
        const erroredConstraints = errors
            .map(error => error.sourceValidator);

        for (const { template, constraintValidator } of this._constraints) {
            if (erroredConstraints.includes(constraintValidator)) {
                template.state = ConstraintState.Error;
            } else {
                template.state = ConstraintState.Done;
            }
        }

        this.validationDelegate.didSetStateTo(this, erroredConstraints.length === 0);
    }

    /**
     * Foreign synchronizes
     * @param {ForeignInteractor} interactor
     * @param {string} key
     * @param {number} time - delay in the queue see repsective interactor fn
     */
    foreignSynchronize(interactor, key, time = 70) {
        this.input.userInput.addEventListener('input', (event) => {
            interactor.queueKey(key, time, this.input.value);
        });
    }

    /**
     * Sets the tooltip text
     * @param {string} tooltip
     */
    async setTooltip(tooltip) {
        this._tooltipPlaceholder.title = tooltip;
        this._tooltipPlaceholder.appendChild(
            await SVG.load('info')
        );
        tippy.one(this._tooltipPlaceholder, {
            arrow: true,
            animateFill: false,
            duration: [150, 250],
            size: 'small'
        });
    }
}