AutosaveForm
Keeps a draft in the browser as it is typed, offers to restore or discard it, says when saving failed, and works with storage blocked.
Demo
on_status:
Send it to see on_submit.
Get it
It needs theme.qwe, as every component does; 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/autosave_form.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/autosave_form.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/autosave_form.qwe
let autosave_form_demo_fields = [ { name: "title", label: "Title", required: true, placeholder: "Short and clear" }, { name: "body", label: "Your story", type: "textarea", hint: "Kept as you type." }, { name: "topic", label: "Topic", type: "select", options: ["Travel", "Food", "Work"] }, { name: "public", label: "Show it to everyone", type: "checkbox" }, { name: "code", label: "Access code", type: "password", hint: "Never kept in a draft." },]component AutosaveFormDemoLive() { state visit = 1 state said = "Send it to see on_submit." state last = "" <div.demo-stack> <AutosaveForm key={visit} id="autosave-form-demo" store="demo-story" every_ms={100} fields={autosave_form_demo_fields} on_submit={(v) => { said = `on_submit: ${v.title}` }} on_status={(s) => { last = s }}/> <div.demo-row> <button type="button" data-visit="true" @click={() => { visit += 1 }}>Come back later</button> <span.demo-note data-last="true">{`on_status: ${last}`}</span> </div> <p.demo-note data-said="true">{said}</p> </div>}view AutosaveFormDemo() = <AutosaveFormDemoLive/>Source
ui/autosave_form.qwe
// AutosaveForm: a form that keeps a draft in the browser as it is typed. Coming back// with a draft waiting, it offers to restore or discard it, and until the choice is// made nothing overwrites it. It says when a draft is saved and when saving failed// (storage full, or blocked by the browser), in which case the form works on and only// the draft is lost. Sending the form clears the draft. Password fields and those marked// `secret` are never written to storage.//// import ui/autosave_form.qwe use AutosaveForm//// <AutosaveForm id="story" store="story-draft" action="/stories" fields={[// { name: "title", label: "Title", required: true },// { name: "body", label: "Your story", type: "textarea" },// { name: "public", label: "Show it to everyone", type: "checkbox" },// ]}/>// <AutosaveForm id="note" fields={fields} on_submit={(v) => { save_note(v) }} on_status={(s) => { status = s }}/>//// A field: name, label, type ("text", "email", "tel", "url", "number", "date",// "password", "textarea", "select", "checkbox"), options (for a select: strings or// { label, value }), required (true), placeholder, hint, value (where it starts), secret// (true: not stored).//// id: a prefix for its ids ("autosave-form"), unique on the page. store: the name of// the draft (the id): two forms with one store share a draft. fields: as above. initial:// a map of starting values, over each field's value. action, method ("post"): where// a plain form sends it. submit: the button's words ("Send"). every_ms: how often// changes are written while typing (500). class: more classes for its outer element.// on_submit(values): inside one of your components, called with the values once// the required fields are filled. on_status(status): called with "saved", "failed",// "blocked", "offered", "restored", "discarded" or "sent".//// Drafts are kept in localStorage under `ui-autosave:<store>`. A draft for fields that// have since gone loses those, and one that cannot be read is cleared and said so.// autosave_form_encode(fields, values, at) and autosave_form_decode(text, fields) are// the format, for a program's own use; decode gives { values, at } or { error }.import ui/theme.qwe use ui_clslet autosave_form_css = css`.ui-asave { display: grid; gap: 14px; min-width: 0; font: 14px/1.45 var(--ui-font); color: var(--ui-fg); }.ui-asave-offer { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 10px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-info-soft); color: var(--ui-fg); font-size: 13px; }.ui-asave-offer span { flex: 1 1 200px; }.ui-asave-field { display: grid; gap: 5px; min-width: 0; }.ui-asave-label { font-size: 13px; font-weight: 500; }.ui-asave-req { margin-left: 2px; color: var(--ui-danger); }.ui-asave-input { box-sizing: border-box; width: 100%; height: 38px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px var(--ui-font); transition: border-color .15s, box-shadow .15s; }textarea.ui-asave-input { height: auto; min-height: 96px; padding: 8px 10px; resize: vertical; }.ui-asave-input:hover { border-color: var(--ui-strong); }.ui-asave-input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-asave-input[aria-invalid="true"] { border-color: var(--ui-danger); }.ui-asave-check { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; }.ui-asave-check input { margin: 0; accent-color: var(--ui-accent); }.ui-asave-check input:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-asave-hint { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-asave-error { margin: 0; color: var(--ui-danger); font-size: 13px; }.ui-asave-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }.ui-asave-status { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-asave-status[data-status="failed"], .ui-asave-status[data-status="blocked"] { color: var(--ui-warning); }.ui-asave-btn { height: 30px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 13px/1 var(--ui-font); cursor: pointer; }.ui-asave-btn:hover { border-color: var(--ui-strong); background: var(--ui-subtle); }.ui-asave-btn:focus-visible, .ui-asave-send:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-asave-send { height: 38px; padding: 0 16px; border: 1px solid var(--ui-accent); border-radius: var(--ui-radius-sm); background: var(--ui-accent); color: var(--ui-accent-fg); font: 600 14px/1 var(--ui-font); cursor: pointer; }.ui-asave-send:hover { background: color-mix(in srgb, var(--ui-accent) 88%, var(--ui-fg)); }@media (prefers-reduced-motion: reduce) { .ui-asave-input { transition: none; } }`// Whether a field may be written down.fn autosave_form_keeps(f: map) -> bool = f.type != "password" && f.secret != truefn autosave_form_clean(fields) -> list = (type_of(fields) == "list" ? fields : []) | filter((f) => type_of(f) == "map" && type_of(f.name) == "str" && f.name != "")fn autosave_form_options(f: map) -> list = (type_of(f.options) == "list" ? f.options : []) | map((o) => type_of(o) == "map" ? { label: str(o.label ?? o.value), value: str(o.value ?? o.label) } : { label: str(o), value: str(o) })// The values the fields start with.fn autosave_form_start(fields: list, initial: map) -> map = reduce(fields, {}, (m, f) => set(m, f.name, get(initial, f.name) ?? f.value ?? (f.type == "checkbox" ? false : "")))// A draft as text.fn autosave_form_encode(fields: list, values: map, at: int) -> str { let kept = reduce(fields | filter(autosave_form_keeps), {}, (m, f) => set(m, f.name, get(values, f.name))) json.stringify({ v: 1, at, values: kept })}// A draft from text: { values, at } with only what fits the fields, or { error }.fn autosave_form_decode(text, fields: list) -> map { let bad = { error: "A saved draft could not be read, so it was cleared." } if type_of(text) != "str" { return bad } let d = json.parse(text) if is_err(d) || type_of(d) != "map" || d.v != 1 || type_of(d.values) != "map" { return bad } let fit = (f: map, v) => f.type == "checkbox" ? type_of(v) == "bool" : type_of(v) == "str" let values = reduce(fields | filter(autosave_form_keeps), {}, (m, f) => has(d.values, f.name) && fit(f, get(d.values, f.name)) ? set(m, f.name, get(d.values, f.name)) : m) { values, at: type_of(d.at) == "int" ? d.at : null }}// Whether a draft says anything the form does not already say.fn autosave_form_differs(draft: map, values: map) -> bool = any(keys(draft), (k) => get(draft, k) != get(values, k))// The fields that are required and empty.fn autosave_form_missing(fields: list, values: map) -> list = fields | filter((f) => { let v = get(values, f.name) f.required == true && (v == null || v == false || (type_of(v) == "str" && trim(v) == "")) })// One field and its label, hint and error.view AutosaveFormField(id: str, f: map, value = null, error: bool = false, on_value = null) { let t = f.type ?? "text" let fid = `${id}-${f.name}` let hint_id = f.hint != null ? `${fid}-hint` : null let about = ui_cls([hint_id, error && `${fid}-error`]) let said = about != "" ? about : null let bad = error ? "true" : null let star = f.required == true && <span.ui-asave-req aria-hidden="true">*</span> <div.ui-asave-field data-field={f.name}> {t != "checkbox" && <label.ui-asave-label for={fid}>{f.label ?? f.name}{star}</label>} {match t { "textarea" => <textarea.ui-asave-input id={fid} name={f.name} placeholder={f.placeholder} value={str(value ?? "")} aria-invalid={bad} aria-describedby={said} @input={(e) => { on_value(f.name, e.value) }}></textarea> "select" => <select.ui-asave-input id={fid} name={f.name} aria-invalid={bad} aria-describedby={said} @change={(e) => { on_value(f.name, e.value) }}> <option value="" selected={str(value ?? "") == ""}>{f.placeholder ?? "Choose one"}</option> {for o in autosave_form_options(f) => <option key={o.value} value={o.value} selected={o.value == str(value ?? "")}>{o.label}</option>} </select> "checkbox" => <label.ui-asave-check> <input type="checkbox" id={fid} name={f.name} value="on" checked={value == true} aria-invalid={bad} aria-describedby={said} @change={(e) => { on_value(f.name, e.checked) }}/> <span>{f.label ?? f.name}{star}</span> </label> _ => <input.ui-asave-input type={t} id={fid} name={f.name} placeholder={f.placeholder} value={str(value ?? "")} aria-invalid={bad} aria-describedby={said} autocomplete={t == "password" ? "off" : null} @input={(e) => { on_value(f.name, e.value) }}/> }} {f.hint != null && <p.ui-asave-hint id={hint_id}>{f.hint}</p>} {error && <p.ui-asave-error id={`${fid}-error`}>{`${f.label ?? f.name} is required.`}</p>} </div>}component AutosaveForm(id: str = "autosave-form", store: str? = null, fields = [], initial: map = {}, action: str? = null, method: str = "post", submit: str = "Send", every_ms: int = 500, on_submit = null, on_status = null, class: str? = null) { style global autosave_form_css let clean = autosave_form_clean(fields) let slot = `ui-autosave:${store ?? id}` state vals = autosave_form_start(autosave_form_clean(fields), initial) state offer = null state pending = false state status = "" state message = "" state tried = false let missing = autosave_form_missing(clean, vals) | map((f) => f.name) let say = (s: str, words: str) => { status = s message = words on_status && s != "" && on_status(s) } // What storage throws (blocked, full) ends a handler, and Quillo has no `try`. Inside // a Promise it becomes an error value here instead, and no name goes on the page. let flush = () => { pending = false if offer == null { let text = autosave_form_encode(clean, vals, now()) let wrote = js("Promise.resolve", js_new("Promise", (done) => { done(js("localStorage.setItem", slot, text) ?? true) })) if is_err(wrote) { say("failed", "Could not save a draft: storage is full or blocked. Your typing is safe on this page until you leave it.") } else { say("saved", `Draft saved at ${js_new("Date").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`) } } } if pending { every(every_ms, flush) } let forget = () => { let gone = js("Promise.resolve", js_new("Promise", (done) => { done(js("localStorage.removeItem", slot) ?? true) })) !is_err(gone) } let change = (name: str, v) => { vals = set(vals, name, v) pending = true } let begin = (el) => { let text = js("Promise.resolve", js_new("Promise", (done) => { done(js("localStorage.getItem", slot)) })) if is_err(text) { say("blocked", "Drafts are off: this browser is blocking storage. The form still works.") } else if text != null { let draft = autosave_form_decode(text, clean) if draft.error != null { forget() say("failed", draft.error) } else if autosave_form_differs(draft.values, vals) { offer = draft say("offered", "") } } } <form class={ui_cls(["ui-asave", class])} id={id} action={action} method={method} novalidate="true" @mount={begin} @submit={(e) => { e.preventDefault() tried = true let wanting = autosave_form_missing(clean, vals) if len(wanting) > 0 { let el = e.target.querySelector(`[name="${wanting[0].name}"]`) el && el.focus() } else { pending = false offer = null forget() say("sent", "Sent. The draft is cleared.") tried = false on_submit && on_submit(vals) if action != null { e.target.submit() } } }}> {offer != null && <div.ui-asave-offer role="group" aria-label="A saved draft"> <span data-offer="true">{offer.at != null ? `A draft from ${js_new("Date", offer.at).toLocaleString([], { dateStyle: "medium", timeStyle: "short" })} is saved in this browser.` : "A draft is saved in this browser."}</span> <button.ui-asave-btn type="button" data-restore="true" @click={() => { vals = merge(vals, offer.values) offer = null say("restored", "Draft restored.") }}>Restore</button> <button.ui-asave-btn type="button" data-discard="true" @click={() => { offer = null forget() say("discarded", "Draft discarded.") }}>Discard</button> </div>} {for f in clean => <AutosaveFormField key={f.name} id={id} f={f} value={get(vals, f.name)} error={tried && contains(missing, f.name)} on_value={change}/>} <div.ui-asave-foot> <button.ui-asave-send type="submit">{submit}</button> <p.ui-asave-status role="status" aria-live="polite" data-status={status}>{message}</p> </div> </form>}Related
DirtyFormGuardCompares 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 downloadsFormBuilderAdd, reorder and set up fields beside a live preview; names cannot repeat, and the form goes out as JSON that also loads back.3 views · 6 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 AutosaveForm: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.