@thi.ng/rdom-forms
Version:
Data-driven declarative & extensible HTML form generation
550 lines (549 loc) • 14.6 kB
JavaScript
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 { defmulti } from "@thi.ng/defmulti/defmulti";
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
} from "@thi.ng/rdom";
const form = (attribs, ...items) => ({
type: "form",
attribs,
items
});
const container = (attribs, ...items) => ({
type: "container",
attribs,
items
});
const group = (spec, ...items) => ({
...spec,
type: "group",
items
});
const custom = (body) => ({
type: "custom",
body
});
const hidden = (spec) => ({
type: "hidden",
...spec
});
let __nextID = 0;
const $ = (type, defaults) => (spec) => ({
id: spec.id || `${type}-${__nextID++}`,
type,
...defaults,
...spec
});
const color = $("color");
const date = $("date");
const dateTime = $("dateTime");
const email = $("email", { autocomplete: true });
const file = $("file");
const month = $("month");
const multiFile = $("multiFile");
const num = $("num");
const password = $("password", { autocomplete: true });
const phone = $("tel", { autocomplete: true });
const radioNum = $("radioNum");
const radioStr = $("radioStr");
const range = $("range");
const reset = $("reset");
const search = $("search");
const str = $("str");
const submit = $("submit");
const text = $("text");
const time = $("time");
const toggle = $("toggle");
const trigger = $("trigger");
const url = $("url");
const week = $("week");
const selectNum = (spec) => $("selectNum")(spec);
const selectStr = (spec) => $("selectStr")(spec);
const multiSelectNum = (spec) => $("multiSelectNum")(spec);
const multiSelectStr = (spec) => $("multiSelectStr")(spec);
const __genID = (id, opts) => opts.prefix ? opts.prefix + id : id;
const __genLabel = (x, opts) => label(
{ ...opts.labelAttribs, ...x.labelAttribs, for: __genID(x.id, opts) },
x.label != void 0 ? x.label || null : x.id,
x.desc ? span({ ...opts.descAttribs, ...x.descAttribs }, x.desc) : null
);
const __genList = (id, list) => datalist({ id: id + "--list" }, ...list.map((value) => option({ value })));
const __genCommon = (val, opts) => {
const res = [];
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;
};
const __attribs = (attribs, events, val, opts, value = "value") => {
const id = __genID(val.id, opts);
Object.assign(attribs, {
id,
name: val.name || val.id,
list: val.list ? id + "--list" : void 0,
required: val.required,
readonly: val.readonly
});
if (__useValues(opts)) {
if (!val.readonly && val.value !== void 0) {
Object.assign(attribs, events);
}
if (value !== false) {
attribs[value] = val.value;
}
}
if (val.attribs) Object.assign(attribs, val.attribs);
return attribs;
};
const __component = (val, opts, el, attribs, events, value = "value", ...body) => div(
{ ...opts.wrapperAttribs, ...val.wrapperAttribs },
...__genCommon(val, opts),
// @ts-ignore extra args
el(__attribs(attribs, events, val, opts, value), ...body)
);
const __edit = (val) => {
if (val.pattern) {
let match;
if (isFunction(val.pattern)) {
match = val.pattern;
} else {
const re = isString(val.pattern) ? new RegExp(val.pattern) : val.pattern;
match = (x) => re.test(x);
}
return (e) => {
const target = 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) => opts.behaviors?.values !== false;
const compileForm = defmulti(
(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 = $val;
return $form(
{ ...opts.typeAttribs?.form, ...val.attribs },
...val.items.map((x) => compileForm(x, opts))
);
},
container: ($val, opts) => {
const val = $val;
return div(
{ ...opts.typeAttribs?.container, ...val.attribs },
...val.items.map((x) => compileForm(x, opts))
);
},
group: ($val, opts) => {
const val = $val;
const children = [];
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) => val.body,
hidden: ($val) => {
const { id, name, value } = $val;
return inputText({ type: "hidden", id: id ?? name, name, value });
},
toggle: ($val, opts) => {
const val = $val;
const label2 = __genLabel(val, opts);
const ctrl = checkbox(
__attribs(
{ ...opts.typeAttribs?.toggle },
{ onchange: $inputCheckbox($val.value) },
val,
opts,
"checked"
)
);
return div(
{ ...opts.wrapperAttribs, ...val.wrapperAttribs },
...opts.behaviors?.toggleLabelBefore !== false ? [label2, ctrl] : [ctrl, label2]
);
},
trigger: ($val, opts) => __component(
$val,
opts,
button,
{ ...opts.typeAttribs?.trigger },
{ onclick: $inputTrigger($val.value) },
false,
$val.title
),
submit: ($val, opts) => __component(
$val,
opts,
inputSubmit,
{ ...opts.typeAttribs?.submit, value: $val.title },
{ onclick: $inputTrigger($val.value) },
false
),
reset: ($val, opts) => __component(
$val,
opts,
inputReset,
{ ...opts.typeAttribs?.reset, value: $val.title },
{ onclick: $inputTrigger($val.value) },
false
),
radio: ($val, opts) => {
const val = $val;
const labelAttribs = {
...opts.typeAttribs?.radioItemLabel,
...val.labelAttribs
};
const $option = ($item) => {
const item = isPlainObject($item) ? $item : { value: $item };
const id = val.id + "-" + item.value;
const label2 = __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) : void 0,
id: __genID(id, opts),
name: val.name || val.id,
checked: val.value && __useValues(opts) ? val.value.map((x) => x === item.value) : void 0,
value: item.value
});
return div(
{ ...opts.typeAttribs?.radioItem },
...opts.behaviors?.radioLabelBefore ? [label2, ctrl] : [ctrl, label2]
);
};
return div(
{
...opts.wrapperAttribs,
...opts.typeAttribs?.radioWrapper,
...val.wrapperAttribs
},
...__genCommon(val, opts),
div(
{ ...opts.typeAttribs?.radioItems },
...val.items.map($option)
)
);
},
color: ($val, opts) => __component(
$val,
opts,
inputColor,
{ ...opts.typeAttribs?.color },
{ onchange: $input($val.value) }
),
file: ($val, opts) => {
const val = $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($val.value) : $inputFile(val.value)
},
false
);
},
num: ($val, opts) => {
const val = $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 = $val;
const edit = opts.behaviors?.rangeOnInput === false ? "onchange" : "oninput";
const children = [
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 === void 0 ? 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 = $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: val.autocomplete,
minlength: val.min,
maxlength: val.max,
placeholder: val.placeholder,
pattern: isString(val.pattern) ? val.pattern : void 0,
size: val.size
},
{ [edit]: __edit(val) }
);
},
text: ($val, opts) => {
const val = $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 = $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 = $val;
const isNumeric = val.type.endsWith("Num");
const $option = ($item, sel) => {
const item = isPlainObject($item) ? $item : { value: $item };
return option(
{
value: item.value,
selected: sel === item.value
},
item.label || item.value
);
};
const $select = (sel) => 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 = $val;
const isNumeric = val.type.endsWith("Num");
const coerce = 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) => {
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) => {
val.value.next(
[
...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)
)
);
}
}
);
export {
color,
compileForm,
container,
custom,
date,
dateTime,
email,
file,
form,
group,
hidden,
month,
multiFile,
multiSelectNum,
multiSelectStr,
num,
password,
phone,
radioNum,
radioStr,
range,
reset,
search,
selectNum,
selectStr,
str,
submit,
text,
time,
toggle,
trigger,
url,
week
};