FormBuilder
Add, reorder and set up fields beside a live preview; names cannot repeat, and the form goes out as JSON that also loads back.
Demo
Form builder
Add a field
Fields
Field settings
Preview
JSON
Change the form to see on_change.
Get it
It needs theme.qwe, as every component does, and rule_form.qwe and regex_tester.qwe; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/rule_form.qwe$ curl -O https://quillolang.org/components/ui/regex_tester.qwe$ curl -O https://quillolang.org/components/ui/form_builder.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/form_builder.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/form_builder.qwe
let form_builder_demo_fields = [ { name: "full_name", label: "Full name", type: "text", required: true, placeholder: "Ana Silva" }, { name: "email", label: "Email", type: "email", required: true, hint: "We reply here." }, { name: "topic", label: "Topic", type: "select", options: ["Sales", "Support"] }, { name: "details", label: "Details", type: "textarea", show_if: { field: "topic", is: "Support" } },]component FormBuilderDemoLive() { state said = "Change the form to see on_change." <div.demo-stack> <FormBuilder id="form-builder-demo" fields={form_builder_demo_fields} name="form_json" on_change={(f) => { said = `on_change: ${len(f)} fields, the last ${f[len(f) - 1].name ?? "none"}.` }}/> <p.demo-note data-said="true">{said}</p> </div>}view FormBuilderDemo() = <FormBuilderDemoLive/>Source
ui/form_builder.qwe
// FormBuilder: build a form by hand. Add fields from a palette, reorder them, set// each one's label, name, type, hint and options in a panel beside a live preview,// then take the result as JSON, which also loads back. Two fields can never share a// name, in any case: a new or copied field gets a free one, and typing one already// taken says so and keeps the old name. The JSON is what RuleForm takes as `fields`,// and keys this builder does not edit (show_if, rules...) are kept as they came.//// import ui/form_builder.qwe use FormBuilder//// <FormBuilder id="survey" fields={[{ name: "email", label: "Email", type: "email", required: true }]}/>// <FormBuilder id="survey" on_change={(fields) => { saved = fields }}/>//// fields: the fields to start with, as RuleForm reads them, a list or JSON text; what// is wrong with them is shown and they are left out. id: a prefix for its ids// ("form-builder"), unique on the page. title: the heading ("Form builder"). name: when// given, a hidden input of that name holds the JSON, so a form around it sends it.// class: more classes for its outer element.// on_change(fields): inside one of your components, called after each change with the// list of fields, as the JSON has them.//// form_builder_json(fields) is the JSON text, form_builder_read(text or list) reads// it back as { fields } or { errors }, and form_builder_rename(fields, i, name) gives// { fields } or { error }: the logic, for a program's own use. At most 200 fields, and// 200,000 characters of JSON.//// Keyboard: everything is a button or a field; Alt and the up or down arrow move the// field whose row has focus, and what happened is announced.import ui/theme.qwe use ui_clsimport ui/rule_form.qwe use RuleForm, rule_form_typeslet form_builder_css = css`.ui-fbuild { display: grid; gap: 14px; min-width: 0; padding: 20px 22px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.45 var(--ui-font); }.ui-fbuild .ui-fbuild-title { margin: 0; font-size: 16px; font-weight: 650; }.ui-fbuild .ui-fbuild-sub { margin: 0; font-size: 13px; font-weight: 600; }.ui-fbuild-panes { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; align-items: start; }.ui-fbuild-pane { display: grid; gap: 10px; min-width: 0; }.ui-fbuild-palette { display: flex; flex-wrap: wrap; gap: 4px; }.ui-fbuild-btn { height: 28px; padding: 0 9px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 12px/1 var(--ui-font); cursor: pointer; }.ui-fbuild-btn:hover { border-color: var(--ui-strong); background: var(--ui-subtle); }.ui-fbuild-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-fbuild-btn[aria-disabled="true"] { opacity: .45; cursor: default; }.ui-fbuild-list { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }.ui-fbuild-row { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 4px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); }.ui-fbuild-on { border-color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-fbuild-pick { flex: 1 1 120px; min-width: 0; padding: 4px 6px; border: 0; border-radius: 4px; background: none; color: var(--ui-fg); font: 13px/1.3 var(--ui-font); text-align: left; cursor: pointer; overflow-wrap: anywhere; }.ui-fbuild-pick small { display: block; color: var(--ui-muted); font: 11px var(--ui-mono); }.ui-fbuild-pick:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-fbuild-empty { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-fbuild-field { display: grid; gap: 4px; min-width: 0; }.ui-fbuild-field label { font-size: 13px; font-weight: 500; }.ui-fbuild-input { box-sizing: border-box; width: 100%; height: 34px; padding: 0 8px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 13px var(--ui-font); }textarea.ui-fbuild-input { height: 96px; padding: 6px 8px; font-family: var(--ui-mono); resize: vertical; }.ui-fbuild-input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-fbuild-input[aria-invalid="true"] { border-color: var(--ui-danger); }.ui-fbuild-why { margin: 0; color: var(--ui-danger); font-size: 13px; overflow-wrap: anywhere; }.ui-fbuild-tick { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; }.ui-fbuild-tick input { margin: 0; accent-color: var(--ui-accent); }.ui-fbuild-tick input:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-fbuild-preview { padding: 12px 14px; border: 1px dashed var(--ui-strong); border-radius: var(--ui-radius-sm); }.ui-fbuild-json { display: grid; gap: 8px; min-width: 0; }.ui-fbuild-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }.ui-fbuild-said { color: var(--ui-muted); font-size: 13px; }.ui-fbuild-errors { margin: 0; padding-left: 18px; color: var(--ui-danger); font-size: 13px; overflow-wrap: anywhere; }`let form_builder_most_fields = 200let form_builder_most_text = 200000let form_builder_kinds = [ { type: "text", label: "Text" }, { type: "email", label: "Email" }, { type: "url", label: "Link" }, { type: "tel", label: "Phone" }, { type: "number", label: "Number" }, { type: "integer", label: "Whole number" }, { type: "date", label: "Date" }, { type: "textarea", label: "Long text" }, { type: "select", label: "Dropdown" }, { type: "radio", label: "One of" }, { type: "checkbox", label: "Tick box" }, { type: "multi", label: "Several of" },]fn form_builder_has_options(kind) -> bool = contains(["select", "radio", "multi"], kind)fn form_builder_kind_label(kind: str) -> str = (find(form_builder_kinds, (k) => k.type == kind) ?? { label: kind }).label// The same name in any case: forms and databases tell Email from email badly.fn form_builder_taken(fields: list, name: str, except: int) -> bool = any(enumerate(fields), (p) => p[0] != except && lower(p[1].name) == lower(name))// What is wrong with a name, or null.fn form_builder_name_error(fields: list, i: int, name) { let t = type_of(name) == "str" ? trim(name) : "" if t == "" { return "A field needs a name." } if !re.test(t, "^[A-Za-z_][A-Za-z0-9_]*$") { return "A name is letters, digits and _, and does not start with a digit." } if len(t) > 64 { return "A name is at most 64 characters." } if form_builder_taken(fields, t, i) { return `Another field is already named ${t}.` } null}// A free name: `text`, then `text_2`...fn form_builder_free_name(fields: list, base: str) -> str { let n = 1 let name = base while form_builder_taken(fields, name, -1) { n += 1 name = `${base}_${n}` } name}// The list with field i renamed: { fields } or { error }.fn form_builder_rename(fields: list, i: int, name) -> map { let wrong = form_builder_name_error(fields, i, name) wrong != null ? { error: wrong } : { fields: set(fields, i, { ...fields[i], name: trim(name) }) }}// The list with item i moved by one place (-1 up, 1 down); the ends stay put.fn form_builder_move(fields: list, i: int, by: int) -> list { let j = i + by j < 0 || j >= len(fields) ? fields : set(set(fields, i, fields[j]), j, fields[i])}// The options of one line each, without blanks and repeats.fn form_builder_lines(text: str) -> list = unique(split(text, "\n") | map(trim) | filter((l) => l != ""))fn form_builder_new(fields: list, kind: str, uid: int) -> map { let name = form_builder_free_name(fields, kind) let base = { uid, name, label: form_builder_kind_label(kind), type: kind } form_builder_has_options(kind) ? { ...base, options: ["Option 1", "Option 2"] } : base}// A field as JSON: the keys people read first, then whatever else it came with.fn form_builder_field_json(f: map) -> str { let known = ["name", "label", "type", "required", "placeholder", "hint", "options"] let wanted = (k: str) => { let v = get(f, k) if k == "options" { return form_builder_has_options(f.type) && v != null } if k == "required" { return v == true } k == "name" || k == "label" || k == "type" || (v != null && v != "") } let rest = sort(keys(f)) | filter((k) => k != "uid" && !contains(known, k)) let parts = (known | filter(wanted)) + rest " { " + join(parts | map((k) => `${json.stringify(k)}: ${json.stringify(get(f, k))}`), ", ") + " }"}// The fields as JSON text, in the same order of keys on the server and in the browser.fn form_builder_json(fields: list) -> str = len(fields) == 0 ? "[]\n" : "[\n" + join(fields | map(form_builder_field_json), ",\n") + "\n]\n"// Fields as the list a program gets: without the ids the builder keeps them apart by.fn form_builder_plain(fields: list) -> list = fields | map((f) => remove(f, "uid"))// Reads fields from JSON text or a list: { fields } or { errors }, each saying which.fn form_builder_read(source) -> map { let text = type_of(source) == "str" if text && len(source) > form_builder_most_text { return { errors: [`That is ${len(source)} characters; at most ${form_builder_most_text} are read.`] } } if text && trim(source) == "" { return { errors: ["There is nothing to load."] } } let data = text ? json.parse(source) : source if is_err(data) { return { errors: ["That is not JSON: check for a missing comma, quote or bracket."] } } let items = type_of(data) == "map" ? data.fields : data if type_of(items) != "list" { return { errors: ["The JSON must be a list of fields, or a map with `fields`."] } } if len(items) > form_builder_most_fields { return { errors: [`That has ${len(items)} fields; at most ${form_builder_most_fields} are taken.`] } } let errors = [] let good = [] for [i, f] in enumerate(items) { let at = `Field ${i + 1}` if type_of(f) != "map" { errors = push(errors, `${at} is not a map.`) } else { let kind = f.type ?? "text" let wrong = form_builder_name_error(good, -1, f.name) if !contains(rule_form_types, kind) { errors = push(errors, `${at}: there is no type "${str(kind)}".`) } else if wrong != null { errors = push(errors, `${at}: ${wrong}`) } else if form_builder_has_options(kind) && type_of(f.options) != "list" { errors = push(errors, `${at}: ${f.name} needs a list of options.`) } else { good = push(good, { ...remove(f, "uid"), name: trim(f.name), label: type_of(f.label) == "str" ? f.label : f.name, type: kind, uid: i + 1 }) } } } len(errors) > 0 ? { errors } : { fields: good }}component FormBuilder(fields = [], id: str = "form-builder", title: str = "Form builder", name: str? = null, on_change = null, class: str? = null) { style global form_builder_css let start = form_builder_read(fields) state list_of = start.fields ?? [] state next_uid = len(start.fields ?? []) + 1 state chosen = len(start.fields ?? []) > 0 ? start.fields[0].uid : null state name_text = len(start.fields ?? []) > 0 ? start.fields[0].name : "" state name_why = null state said = "" state load_text = "" state load_errors = start.errors ?? [] let at = index_of(list_of | map((f) => f.uid), chosen) let current = at != null ? list_of[at] : null let output = form_builder_json(list_of) let changed = (next: list, what: str) => { list_of = next said = what on_change && on_change(form_builder_plain(next)) } let choose = (f) => { chosen = f != null ? f.uid : null name_text = f != null ? f.name : "" name_why = null } let edit = (key: str, v) => { let i = index_of(list_of | map((f) => f.uid), chosen) if i != null { changed(set(list_of, i, set(list_of[i], key, v)), "") } } let add = (kind: str) => { if len(list_of) >= form_builder_most_fields { said = `A form here has at most ${form_builder_most_fields} fields.` } else { let f = form_builder_new(list_of, kind, next_uid) next_uid += 1 changed(push(list_of, f), `Added ${f.name}`) choose(f) } } let move = (f, by: int) => { let i = index_of(list_of | map((x) => x.uid), f.uid) let j = i + by if j >= 0 && j < len(list_of) { changed(form_builder_move(list_of, i, by), `Moved ${f.name} to place ${j + 1} of ${len(list_of)}`) } } let copy_field = (f) => { if len(list_of) >= form_builder_most_fields { said = `A form here has at most ${form_builder_most_fields} fields.` } else { let i = index_of(list_of | map((x) => x.uid), f.uid) let twin = { ...f, uid: next_uid, name: form_builder_free_name(list_of, f.name) } next_uid += 1 changed(take(list_of, i + 1) + [twin] + drop(list_of, i + 1), `Copied ${f.name} as ${twin.name}`) choose(twin) } } let drop_field = (e, f) => { let rest = list_of | filter((x) => x.uid != f.uid) let i = index_of(list_of | map((x) => x.uid), f.uid) if f.uid == chosen { choose(len(rest) > 0 ? rest[min([i, len(rest) - 1])] : null) } // The button goes with its row: focus stays in the panel, not at the page's top. e.target.closest(".ui-fbuild").querySelector(".ui-fbuild-btn").focus() changed(rest, `Removed ${f.name}`) } let load = () => { let r = form_builder_read(load_text) if r.errors != null { load_errors = r.errors } else { load_errors = [] let top = reduce(r.fields, 0, (m, f) => max([m, f.uid])) next_uid = top + 1 changed(r.fields, `Loaded ${len(r.fields)} field${len(r.fields) == 1 ? "" : "s"}`) choose(len(r.fields) > 0 ? r.fields[0] : null) load_text = "" } } let copy = () => { if js("isSecureContext") != true { said = "Could not copy: select the JSON instead" } else { js("navigator.clipboard.writeText", output).then((_ = null) => { said = "Copied the JSON" }, (_ = null) => { said = "Could not copy: select the JSON instead" }) } } <section class={ui_cls(["ui-fbuild", class])} id={id} aria-labelledby={`${id}-title`}> <h3.ui-fbuild-title id={`${id}-title`}>{title}</h3> <div.ui-fbuild-panes> <div.ui-fbuild-pane> <h4.ui-fbuild-sub>Add a field</h4> <div.ui-fbuild-palette role="group" aria-label="Add a field"> {for k in form_builder_kinds => <button.ui-fbuild-btn key={k.type} type="button" data-add={k.type} @click={() => add(k.type)}>{k.label}</button>} </div> <h4.ui-fbuild-sub>Fields</h4> {len(list_of) == 0 && <p.ui-fbuild-empty>No fields yet: add one above, or load some below.</p>} <ol.ui-fbuild-list> {for [i, f] in enumerate(list_of) => <li key={f.uid} class={ui_cls(["ui-fbuild-row", f.uid == chosen && "ui-fbuild-on"])} data-row={f.name} @keydown={(e) => { if e.altKey && (e.key == "ArrowUp" || e.key == "ArrowDown") { e.preventDefault() move(f, e.key == "ArrowUp" ? -1 : 1) } }}> <button.ui-fbuild-pick type="button" aria-pressed={f.uid == chosen ? "true" : "false"} @click={() => choose(f)}> {f.label}<small>{`${f.name} · ${form_builder_kind_label(f.type)}`}</small> </button> <button.ui-fbuild-btn type="button" aria-label={`Move ${f.name} up`} aria-disabled={i == 0 ? "true" : null} @click={() => move(f, -1)}>Up</button> <button.ui-fbuild-btn type="button" aria-label={`Move ${f.name} down`} aria-disabled={i == len(list_of) - 1 ? "true" : null} @click={() => move(f, 1)}>Down</button> <button.ui-fbuild-btn type="button" aria-label={`Copy ${f.name}`} @click={() => copy_field(f)}>Copy</button> <button.ui-fbuild-btn type="button" aria-label={`Remove ${f.name}`} @click={(e) => drop_field(e, f)}>Remove</button> </li>} </ol> </div> <div.ui-fbuild-pane> <h4.ui-fbuild-sub>Field settings</h4> {current == null && <p.ui-fbuild-empty>Choose a field to set it up.</p>} {current != null && <div key={current.uid} class="ui-fbuild-pane"> <div.ui-fbuild-field> <label for={`${id}-label`}>Label</label> <input.ui-fbuild-input id={`${id}-label`} value={current.label} autocomplete="off" @input={(e) => edit("label", e.value)}/> </div> <div.ui-fbuild-field> <label for={`${id}-name`}>Name</label> <input.ui-fbuild-input id={`${id}-name`} value={name_text} autocomplete="off" spellcheck="false" aria-invalid={name_why != null ? "true" : null} aria-describedby={name_why != null ? `${id}-name-why` : null} @input={(e) => { name_text = e.value let i = index_of(list_of | map((f) => f.uid), chosen) let r = form_builder_rename(list_of, i, e.value) name_why = r.error if r.fields != null { changed(r.fields, "") } }}/> {name_why != null && <p.ui-fbuild-why id={`${id}-name-why`} role="alert">{`${name_why} It stays ${current.name}.`}</p>} </div> <div.ui-fbuild-field> <label for={`${id}-type`}>Type</label> <select.ui-fbuild-input id={`${id}-type`} @change={(e) => { edit("type", e.value) if form_builder_has_options(e.value) && current.options == null { edit("options", ["Option 1", "Option 2"]) } }}> {for k in form_builder_kinds => <option key={k.type} value={k.type} selected={k.type == current.type}>{k.label}</option>} </select> </div> <label.ui-fbuild-tick><input type="checkbox" id={`${id}-required`} checked={current.required == true} @change={(e) => edit("required", e.checked)}/> Required</label> {current.type != "checkbox" && !form_builder_has_options(current.type) && <div.ui-fbuild-field> <label for={`${id}-placeholder`}>Placeholder</label> <input.ui-fbuild-input id={`${id}-placeholder`} value={current.placeholder ?? ""} autocomplete="off" @input={(e) => edit("placeholder", e.value)}/> </div>} <div.ui-fbuild-field> <label for={`${id}-hint`}>Hint</label> <input.ui-fbuild-input id={`${id}-hint`} value={current.hint ?? ""} autocomplete="off" @input={(e) => edit("hint", e.value)}/> </div> {form_builder_has_options(current.type) && <div.ui-fbuild-field> <label for={`${id}-options`}>Options, one a line</label> <textarea.ui-fbuild-input id={`${id}-options`} value={join((current.options ?? []) | map((o) => type_of(o) == "map" ? str(o.value ?? o.label) : str(o)), "\n")} @change={(e) => edit("options", form_builder_lines(e.value))}></textarea> </div>} </div>} </div> <div.ui-fbuild-pane> <h4.ui-fbuild-sub>Preview</h4> <div.ui-fbuild-preview> {len(list_of) == 0 ? <p.ui-fbuild-empty>The form shows here.</p> : <RuleForm id={`${id}-preview`} fields={form_builder_plain(list_of)} report={false} submit="Try it"/>} </div> </div> </div> <div.ui-fbuild-json> <h4.ui-fbuild-sub>JSON</h4> <div.ui-fbuild-field> <label for={`${id}-json`}>The form as JSON</label> <textarea.ui-fbuild-input id={`${id}-json`} readonly="true" value={output}></textarea> </div> <div.ui-fbuild-bar> <button.ui-fbuild-btn type="button" data-copy="true" @click={copy}>Copy JSON</button> <span.ui-fbuild-said role="status" aria-live="polite">{said}</span> </div> <div.ui-fbuild-field> <label for={`${id}-load`}>Load JSON</label> <textarea.ui-fbuild-input id={`${id}-load`} value={load_text} spellcheck="false" aria-invalid={len(load_errors) > 0 ? "true" : null} aria-describedby={len(load_errors) > 0 ? `${id}-load-why` : null} @input={(e) => { load_text = e.value }}></textarea> </div> {len(load_errors) > 0 && <ul.ui-fbuild-errors id={`${id}-load-why`} role="alert"> {for [i, t] in enumerate(load_errors) => <li key={i}>{t}</li>} </ul>} <div.ui-fbuild-bar><button.ui-fbuild-btn type="button" data-load="true" @click={load}>Load it</button></div> </div> {name != null && <input type="hidden" name={name} value={output}/>} </section>}Related
AutosaveFormKeeps a draft in the browser as it is typed, offers to restore or discard it, says when saving failed, and works with storage blocked.3 views · 5 downloadsDirtyFormGuardCompares a form with its first values: lists what changed, reverts per field or all, warns before leaving, and tells a change put back from a real one.3 views · 5 downloadsRuleFormA form from a data description: fields shown and required by rules, validation and cross-field checks, cycles reported, hidden fields left out of what is sent.3 views · 7 downloadsAmountInputReads 1.5k, 2m, 1,234.50, 1.234,50, $12 and EUR 40, decides which mark groups, shows the normalised amount, flags what could go two ways, and sends a real decimal.3 views · 8 downloadsBarcodeInputTells EAN-13, EAN-8, UPC-A, ISBN-10 and ISBN-13 apart by shape, checks the check digit, says what the right one would be, and converts ISBN-10 to ISBN-13.2 views · 5 downloadsBranchingSurveyOne question at a time with jumps by answer: back keeps answers, progress is counted from the longest path left, and unreachable or looping questions are reported.3 views · 6 downloads
Thread
Thread 0 posts
On FormBuilder: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.