UNPKG

@thi.ng/rdom-forms

Version:

Data-driven declarative & extensible HTML form generation

550 lines (549 loc) 14.6 kB
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 };