thi-ng/umbrella

View on GitHub
packages/rdom-forms/src/compile.ts

Summary

Maintainability
A
0 mins
Test Coverage
import type { Fn, FnAny, NumOrString, Predicate } from "@thi.ng/api";
import { isArray } from "@thi.ng/checks/is-array";
import { isFunction } from "@thi.ng/checks/is-function";
import { isPlainObject } from "@thi.ng/checks/is-plain-object";
import { isString } from "@thi.ng/checks/is-string";
import type { MultiFn2 } from "@thi.ng/defmulti";
import { defmulti } from "@thi.ng/defmulti/defmulti";
import type { Attribs, FormAttribs } from "@thi.ng/hiccup-html";
import { div } from "@thi.ng/hiccup-html/blocks";
import {
    form as $form,
    button,
    checkbox,
    fieldset,
    inputColor,
    inputFile,
    inputNumber,
    inputRange,
    inputReset,
    inputSubmit,
    inputText,
    label,
    legend,
    optGroup,
    option,
    radio,
    select,
    textArea,
} from "@thi.ng/hiccup-html/forms";
import { span } from "@thi.ng/hiccup-html/inline";
import { datalist } from "@thi.ng/hiccup-html/lists";
import {
    $attribs,
    $input,
    $inputCheckbox,
    $inputFile,
    $inputFiles,
    $inputNum,
    $inputTrigger,
    $replace,
    type ComponentLike,
} from "@thi.ng/rdom";
import type { ISubscribable } from "@thi.ng/rstream";
import type {
    Color,
    Container,
    Custom,
    DateTime,
    DateVal,
    Email,
    FileVal,
    Form,
    FormItem,
    FormOpts,
    Group,
    HiddenValue,
    Month,
    MultiFileVal,
    MultiSelect,
    MultiSelectNum,
    MultiSelectStr,
    Num,
    Password,
    Radio,
    RadioNum,
    RadioStr,
    Range,
    Reset,
    Select,
    SelectItem,
    SelectNum,
    SelectStr,
    Str,
    Submit,
    Text,
    Time,
    Toggle,
    Trigger,
    UrlVal,
    Value,
    Week,
} from "./api.js";

export const form = (
    attribs: Partial<FormAttribs>,
    ...items: FormItem[]
): Form => ({
    type: "form",
    attribs,
    items,
});

export const container = (
    attribs: Partial<Attribs>,
    ...items: FormItem[]
): Container => ({
    type: "container",
    attribs,
    items,
});

export const group = (
    spec: Omit<Group, "type" | "id" | "items"> & { id?: string },
    ...items: FormItem[]
): Group => ({
    ...spec,
    type: "group",
    items,
});

export const custom = (body: ComponentLike): Custom => ({
    type: "custom",
    body,
});

export const hidden = (spec: Omit<HiddenValue, "type">): HiddenValue => ({
    type: "hidden",
    ...spec,
});

let __nextID = 0;

export type PartialSpec<T extends Value> = Omit<T, "type" | "id"> & {
    id?: string;
};

export type ReadonlyPartialSpec<T extends Value, V = string> = Omit<
    T,
    "type" | "id" | "readonly" | "value"
> & {
    id?: string;
    readonly: true;
    value?: ISubscribable<V>;
};

/** @internal */
const $ =
    <T extends Value, V = string>(type: string, defaults?: Partial<T>) =>
    (spec: PartialSpec<T> | ReadonlyPartialSpec<T, V>): T =>
        <any>{
            id: spec.id || `${type}-${__nextID++}`,
            type,
            ...defaults,
            ...spec,
        };

export const color = $<Color>("color");
export const date = $<DateVal>("date");
export const dateTime = $<DateTime>("dateTime");
export const email = $<Email>("email", { autocomplete: true });
export const file = $<FileVal, never>("file");
export const month = $<Month>("month");
export const multiFile = $<MultiFileVal, never>("multiFile");
export const num = $<Num, number>("num");
export const password = $<Password>("password", { autocomplete: true });
export const phone = $<Email>("tel", { autocomplete: true });
export const radioNum = $<RadioNum, number>("radioNum");
export const radioStr = $<RadioStr>("radioStr");
export const range = $<Range, number>("range");
export const reset = $<Reset>("reset");
export const search = $<Str>("search");
export const str = $<Str, string>("str");
export const submit = $<Submit>("submit");
export const text = $<Text>("text");
export const time = $<Time>("time");
export const toggle = $<Toggle, boolean>("toggle");
export const trigger = $<Trigger>("trigger");
export const url = $<UrlVal>("url");
export const week = $<Week>("week");

export const selectNum = <T extends number = number>(
    spec: PartialSpec<SelectNum<T>> | ReadonlyPartialSpec<SelectNum<T>>
) => $<SelectNum<T>>("selectNum")(spec);

export const selectStr = <T extends string = string>(
    spec: PartialSpec<SelectStr<T>> | ReadonlyPartialSpec<SelectStr<T>>
) => $<SelectStr<T>>("selectStr")(spec);

export const multiSelectNum = <T extends number = number>(
    spec:
        | PartialSpec<MultiSelectNum<T>>
        | ReadonlyPartialSpec<MultiSelectNum<T>>
) => $<MultiSelectNum<T>>("multiSelectNum")(spec);

export const multiSelectStr = <T extends string = string>(
    spec:
        | PartialSpec<MultiSelectStr<T>>
        | ReadonlyPartialSpec<MultiSelectStr<T>>
) => $<MultiSelectStr<T>>("multiSelectStr")(spec);

/** @internal */
const __genID = (id: string, opts: Partial<FormOpts>) =>
    opts.prefix ? opts.prefix + id : id;

/** @internal */
const __genLabel = (
    x: Pick<Value, "id" | "label" | "desc" | "labelAttribs" | "descAttribs">,
    opts: Partial<FormOpts>
) =>
    label(
        { ...opts.labelAttribs, ...x.labelAttribs, for: __genID(x.id, opts) },
        x.label != undefined ? x.label || null : x.id,
        x.desc ? span({ ...opts.descAttribs, ...x.descAttribs }, x.desc) : null
    );

/** @internal */
const __genList = (id: string, list: NumOrString[]) =>
    datalist({ id: id + "--list" }, ...list.map((value) => option({ value })));

/** @internal */
const __genCommon = (
    val: Value & { list?: any[] },
    opts: Partial<FormOpts>
) => {
    const res: ComponentLike[] = [];
    if (val.label !== false && opts.behaviors?.labels !== false) {
        res.push(__genLabel(val, opts));
    }
    if (val.list) {
        res.push(__genList(__genID(val.id, opts), val.list));
    }
    return res;
};

/** @internal */
const __attribs = (
    attribs: Partial<Attribs>,
    events: Partial<Attribs>,
    val: Value & { value?: any; list?: any[] },
    opts: Partial<FormOpts>,
    value: string | false = "value"
) => {
    const id = __genID(val.id, opts);
    Object.assign(
        attribs,
        {
            id,
            name: val.name || val.id,
            list: val.list ? id + "--list" : undefined,
            required: val.required,
            readonly: val.readonly,
        },
        val.attribs
    );
    if (__useValues(opts)) {
        if (!val.readonly) {
            Object.assign(attribs, events);
        }
        if (value !== false) {
            attribs[value] = val.value;
        }
    }
    return attribs;
};

/** @internal */
const __component = (
    val: Value & { value?: any; list?: any[] },
    opts: Partial<FormOpts>,
    el: Fn<Partial<Attribs>, ComponentLike> | FnAny<ComponentLike>,
    attribs: Partial<Attribs>,
    events: Partial<Attribs>,
    value: string | false = "value",
    ...body: any[]
) =>
    div(
        { ...opts.wrapperAttribs, ...val.wrapperAttribs },
        ...__genCommon(val, opts),
        // @ts-ignore extra args
        el(__attribs(attribs, events, val, opts, value), ...body)
    );

/** @internal */
const __edit = <T extends Str>(val: T) => {
    if (val.pattern) {
        let match: Predicate<string>;
        if (isFunction(val.pattern)) {
            match = val.pattern;
        } else {
            const re = isString(val.pattern)
                ? new RegExp(val.pattern)
                : val.pattern;
            match = (x: string) => re.test(x);
        }
        return (e: InputEvent) => {
            const target = <HTMLInputElement>e.target;
            const body = target.value;
            const ok = match(body);
            if (ok) val.value!.next(body);
            $attribs(target, { invalid: !ok });
        };
    }
    return $input(val.value!);
};

const __useValues = (opts: Partial<FormOpts>) =>
    opts.behaviors?.values !== false;

/**
 * Compiles given {@link FormItem} spec into a hiccup/rdom component, using
 * provided options to customize attributes and behaviors.
 *
 * @remarks
 * This function is polymorphic and dynamically extensible for new/custom form
 * element types. See thi.ng/defmulti readme for instructions.
 */
export const compileForm: MultiFn2<
    FormItem,
    Partial<FormOpts>,
    ComponentLike
> = defmulti<FormItem, Partial<FormOpts>, ComponentLike>(
    (x) => x.type,
    {
        multiFile: "file",
        dateTime: "date",
        time: "date",
        week: "date",
        month: "date",
        email: "str",
        password: "str",
        tel: "str",
        search: "str",
        url: "str",
        radioNum: "radio",
        radioStr: "radio",
        selectNum: "select",
        selectStr: "select",
        multiSelectNum: "multiSelect",
        multiSelectStr: "multiSelect",
    },
    {
        form: ($val, opts) => {
            const val = <Form>$val;
            return $form(
                { ...opts.typeAttribs?.form, ...val.attribs },
                ...val.items.map((x) => compileForm(x, opts))
            );
        },

        container: ($val, opts) => {
            const val = <Container>$val;
            return div(
                { ...opts.typeAttribs?.container, ...val.attribs },
                ...val.items.map((x) => compileForm(x, opts))
            );
        },

        group: ($val, opts) => {
            const val = <Group>$val;
            const children: ComponentLike[] = [];
            if (val.label) {
                children.push(
                    legend({ ...opts.typeAttribs?.groupLabel }, val.label)
                );
            }
            return fieldset(
                { ...opts.typeAttribs?.group, ...val.attribs },
                ...children,
                ...val.items.map((x) => compileForm(x, opts))
            );
        },

        custom: (val) => (<Custom>val).body,

        hidden: ($val) => {
            const { id, name, value } = <HiddenValue>$val;
            return inputText({ type: "hidden", id: id ?? name, name, value });
        },

        toggle: ($val, opts) => {
            const val = <Toggle>$val;
            const label = __genLabel(val, opts);
            const ctrl = checkbox(
                __attribs(
                    { ...opts.typeAttribs?.toggle },
                    { onchange: $inputCheckbox((<Toggle>$val).value!) },
                    val,
                    opts,
                    "checked"
                )
            );
            return div(
                { ...opts.wrapperAttribs, ...val.wrapperAttribs },
                ...(opts.behaviors?.toggleLabelBefore !== false
                    ? [label, ctrl]
                    : [ctrl, label])
            );
        },

        trigger: ($val, opts) =>
            __component(
                <Trigger>$val,
                opts,
                button,
                { ...opts.typeAttribs?.trigger },
                { onclick: $inputTrigger((<Trigger>$val).value!) },
                false,
                (<Trigger>$val).title
            ),

        submit: ($val, opts) =>
            __component(
                <Submit>$val,
                opts,
                inputSubmit,
                { ...opts.typeAttribs?.submit, value: (<Submit>$val).title },
                { onclick: $inputTrigger((<Submit>$val).value!) },
                false
            ),

        reset: ($val, opts) =>
            __component(
                <Reset>$val,
                opts,
                inputReset,
                { ...opts.typeAttribs?.reset, value: (<Reset>$val).title },
                { onclick: $inputTrigger((<Reset>$val).value!) },
                false
            ),

        radio: ($val, opts) => {
            const val = <Radio<any>>$val;
            const labelAttribs = {
                ...opts.typeAttribs?.radioItemLabel,
                ...val.labelAttribs,
            };
            const $option = ($item: any | SelectItem<any>) => {
                const item = isPlainObject($item) ? $item : { value: $item };
                const id = val.id + "-" + item.value;
                const label = __genLabel(
                    {
                        id,
                        label: item.label || item.value,
                        desc: item.desc,
                        labelAttribs,
                        descAttribs: val.descAttribs,
                    },
                    opts
                );
                const ctrl = radio({
                    ...opts.typeAttribs?.radio,
                    ...val.attribs,
                    onchange:
                        val.value && __useValues(opts)
                            ? () => val.value!.next(item.value)
                            : undefined,
                    id: __genID(id, opts),
                    name: val.name || val.id,
                    checked:
                        val.value && __useValues(opts)
                            ? val.value.map((x) => x === item.value)
                            : undefined,
                    value: item.value,
                });
                return div(
                    { ...opts.typeAttribs?.radioItem },
                    ...(opts.behaviors?.radioLabelBefore
                        ? [label, ctrl]
                        : [ctrl, label])
                );
            };
            return div(
                {
                    ...opts.wrapperAttribs,
                    ...opts.typeAttribs?.radioWrapper,
                    ...val.wrapperAttribs,
                },
                ...__genCommon(val, opts),
                div(
                    { ...opts.typeAttribs?.radioItems },
                    ...val.items.map($option)
                )
            );
        },

        color: ($val, opts) =>
            __component(
                <Color>$val,
                opts,
                inputColor,
                { ...opts.typeAttribs?.color },
                { onchange: $input((<Color>$val).value!) }
            ),

        file: ($val, opts) => {
            const val = <FileVal>$val;
            const isMulti = val.type.startsWith("multi");
            return __component(
                val,
                opts,
                inputFile,
                {
                    ...opts.typeAttribs?.num,
                    accept: val.accept,
                    capture: val.capture,
                    multiple: isMulti,
                },
                {
                    onchange: isMulti
                        ? $inputFiles((<MultiFileVal>$val).value!)
                        : $inputFile(val.value!),
                },
                false
            );
        },

        num: ($val, opts) => {
            const val = <Num>$val;
            return __component(
                val,
                opts,
                inputNumber,
                {
                    ...opts.typeAttribs?.num,
                    min: val.min,
                    max: val.max,
                    step: val.step,
                    placeholder: val.placeholder,
                    size: val.size,
                },
                { onchange: $inputNum(val.value!) }
            );
        },

        range: ($val, opts) => {
            const val = <Range>$val;
            const edit =
                opts.behaviors?.rangeOnInput === false ? "onchange" : "oninput";
            const children: ComponentLike[] = [
                inputRange(
                    __attribs(
                        {
                            ...opts.typeAttribs?.range,
                            min: val.min,
                            max: val.max,
                            step: val.step,
                        },
                        { [edit]: $inputNum(val.value!) },
                        val,
                        opts
                    )
                ),
            ];
            if (val.value && val.vlabel !== false && __useValues(opts)) {
                const fmt =
                    val.vlabel === true || val.vlabel === undefined
                        ? opts.behaviors?.rangeLabelFmt ?? 2
                        : val.vlabel;
                children.push(
                    span(
                        { ...opts.typeAttribs?.rangeLabel },
                        val.value.map(
                            isFunction(fmt) ? fmt : (x) => x.toFixed(fmt)
                        )
                    )
                );
            }
            return div(
                { ...opts.wrapperAttribs, ...val.wrapperAttribs },
                ...__genCommon(val, opts),
                div({ ...opts.typeAttribs?.rangeWrapper }, ...children)
            );
        },

        str: ($val, opts) => {
            const val = <Str>$val;
            const type =
                { dateTime: "datetime-local" }[$val.type] ||
                ($val.type !== "str" ? $val.type : "text");
            const edit =
                opts.behaviors?.strOnInput === false ? "onchange" : "oninput";
            return __component(
                val,
                opts,
                inputText,
                {
                    ...(opts.typeAttribs?.[val.type] || opts.typeAttribs?.str),
                    type,
                    autocomplete: (<any>val).autocomplete,
                    minlength: val.min,
                    maxlength: val.max,
                    placeholder: val.placeholder,
                    pattern: isString(val.pattern) ? val.pattern : undefined,
                    size: val.size,
                },
                { [edit]: __edit(val) }
            );
        },

        text: ($val, opts) => {
            const val = <Text>$val;
            const edit =
                opts.behaviors?.textOnInput === false ? "onchange" : "oninput";
            return __component(
                val,
                opts,
                textArea,
                {
                    ...opts.typeAttribs?.text,
                    cols: val.cols,
                    rows: val.rows,
                    placeholder: val.placeholder,
                },
                { [edit]: $input(val.value!) }
            );
        },

        date: ($val, opts) => {
            const val = <DateVal>$val;
            const type = { dateTime: "datetime-local" }[$val.type] || $val.type;
            return __component(
                val,
                opts,
                inputText,
                {
                    ...(opts.typeAttribs?.[$val.type] ||
                        opts.typeAttribs?.date),
                    type,
                    min: val.min,
                    max: val.max,
                    step: val.step,
                },
                { onchange: $input(val.value!) }
            );
        },

        select: ($val, opts) => {
            const val = <Select<NumOrString>>$val;
            const isNumeric = val.type.endsWith("Num");
            const $option = ($item: any | SelectItem<any>, sel: any) => {
                const item = isPlainObject($item) ? $item : { value: $item };
                return option(
                    {
                        value: item.value,
                        selected: sel === item.value,
                    },
                    item.label || item.value
                );
            };
            const $select = (sel?: NumOrString) =>
                select(
                    __attribs(
                        {
                            ...(opts.typeAttribs?.[val.type] ||
                                opts.typeAttribs?.select),
                        },
                        {
                            onchange: isNumeric
                                ? $inputNum(val.value!)
                                : $input(val.value!),
                        },
                        val,
                        opts,
                        false
                    ),
                    ...val.items.map((item) =>
                        isPlainObject(item) && "items" in item
                            ? optGroup(
                                    { label: item.name },
                                    ...item.items.map((i) => $option(i, sel))
                              )
                            : $option(item, sel)
                    )
                );
            return div(
                { ...opts.wrapperAttribs, ...val.wrapperAttribs },
                ...__genCommon(val, opts),
                val.value && __useValues(opts)
                    ? $replace(val.value.map($select))
                    : $select()
            );
        },

        multiSelect: ($val, opts) => {
            const val = <MultiSelect<NumOrString>>$val;
            const isNumeric = val.type.endsWith("Num");
            const coerce: Fn<HTMLOptionElement, NumOrString> = isNumeric
                ? (x) => parseFloat(x.value)
                : (x) => x.value;
            const sel =
                val.value && __useValues(opts)
                    ? val.value.map((x) => (isArray(x) ? x : [x]))
                    : null;
            const $option = ($item: any | SelectItem<any>) => {
                const item = isPlainObject($item) ? $item : { value: $item };
                return option(
                    {
                        value: item.value,
                        selected: sel
                            ? sel.map(($sel) => $sel.includes(item.value))
                            : false,
                    },
                    item.label || item.value
                );
            };
            return __component(
                val,
                opts,
                select,
                {
                    ...(opts.typeAttribs?.[val.type] ||
                        opts.typeAttribs?.multiSelect),
                    multiple: true,
                    size: val.size,
                },
                {
                    onchange: (e: InputEvent) => {
                        val.value!.next(
                            [
                                ...(<HTMLSelectElement>e.target)
                                    .selectedOptions,
                            ].map(coerce)
                        );
                    },
                },
                false,
                ...val.items.map((item) =>
                    isPlainObject(item) && "items" in item
                        ? optGroup(
                                { label: item.name },
                                ...item.items.map($option)
                          )
                        : $option(item)
                )
            );
        },
    }
);