diff --git a/javascript/form-utils/README.md b/javascript/form-utils/README.md new file mode 100644 index 0000000..2133f06 --- /dev/null +++ b/javascript/form-utils/README.md @@ -0,0 +1,106 @@ +# form-utils (JavaScript) + +Small, dependency‑free helpers to improve form UX and simplify common patterns: +- Auto‑wire typical form behaviors (trim inputs, validate, optional XHR submit) +- File input niceties (mirror selected filename, preview images) +- "Submit on click/change" forms via CSS classes +- Lightweight XHR flow with a minimal JSON response convention +- Radio groups with label state syncing + +## Quick usage + +Minimal setup; adjust paths as needed. + +```html + +
+
+ + + Preview +
+ +
+ + +
+ + +
+ + +``` + +What this does: +- Trims all text/number inputs and textareas before submit +- Validates the form; if valid: + - `.form-xhr` → submits with `XMLHttpRequest` and processes a simple JSON response + - otherwise → performs a normal form submit +- `.form-onchange` → also triggers submit when any field changes +- `.form-onclick` → also triggers submit when the form is clicked +- File input: + - Mirrors the chosen filename to a sibling `input[name="file-path"]` + - If the file is an image and `data-file-preview-target` points to an ``, previews it +- Radio UI: + - In a `.radio-container`, toggles `.radio-checked` on labels whose input is checked + +## Markup conventions + +- Classes used by the helpers: + - `form-xhr` — submit via XHR instead of regular navigation + - `form-onclick` — dispatch a submit event when the form is clicked + - `form-onchange` — dispatch a submit event when any field changes + - `radio-container` — wrapper enabling radio label syncing + - `radio-input-wrapper` — wrapper around each radio/checkbox input + - `radio-checked` — added to a label when its input is checked + +- File preview: + - Add `data-file-preview-target="some-id"` on the file input + - Provide `` to receive the preview + - Optional: add a sibling `input[name="file-path"]` to mirror the filename + +## JSON response convention (XHR) + +When a `.form-xhr` form is submitted, the response is expected to be JSON. Non‑2xx statuses are logged as errors. A minimal, extensible convention is supported: + +```json +{ + "::function": [ + { "name": "log", "params": { "ok": true } } + ] +} +``` + +Currently supported actions: +- `log` — `console.log(params)` + +Unrecognized actions are ignored without error. + +## API (overview) + +All methods are static on `FormUtils`: +- `init(container?)` — wires up inputs, selects, forms, and radio groups within `container` (or `document`) +- `clearFocus()` — blurs the active element +- `readPath(event)` — mirrors selected filename into a sibling `input[name="file-path"]` +- `readImage(event)` — previews chosen image into the `` referenced by `data-file-preview-target` +- `dispatchSubmitEvent(event)` — dispatches a synthetic `submit` on the current form +- `trimInputs(form)` — trims text/number inputs and textareas +- `checkValidity(event)` — validates; normal submit or XHR depending on presence of `.form-xhr` +- `ajaxRequest(form)` / `ajaxResponse(xhr)` — XHR submit and response handling +- `initRadios(container?)` / `radioChangeListener(event)` — label state syncing for radio groups + +Notes: +- Assumes a browser environment (DOM APIs available). +- Non‑blocking and dependency‑free. + +## License + +See the repository‑level `LICENSE` file. diff --git a/javascript/form-utils/form-utils.js b/javascript/form-utils/form-utils.js new file mode 100644 index 0000000..cf9440e --- /dev/null +++ b/javascript/form-utils/form-utils.js @@ -0,0 +1,254 @@ +export default class FormUtils { + // CSS class names + static CLASS = { + formXHR: "form-xhr", + formOnClick: "form-onclick", + formOnChange: "form-onchange", + radioContainer: "radio-container", + radioInputWrapper: "radio-input-wrapper", + radioChecked: "radio-checked" + }; + + // Reusable events + static EVENTS = { + submit: new Event("submit", { + bubbles: true, + cancelable: true + }), + change: new Event("change", { + bubbles: true, + cancelable: true + }), + click: new Event("click", { + bubbles: true, + cancelable: true + }) + }; + + /** Initialize form helpers inside an optional container (defaults to document) + * @param {ParentNode|HTMLElement|null} container + * @return {Promise} + */ + static async init(container = null) { + const htmlRootElement = container ?? document; + + // File inputs: clear focus, mirror path, and preview image + for(const _htmlInputElement of /** @type {NodeListOf} */(htmlRootElement.querySelectorAll("input[type='file']"))) { + _htmlInputElement.addEventListener("change", FormUtils.clearFocus); + _htmlInputElement.addEventListener("change", FormUtils.readPath); + _htmlInputElement.addEventListener("change", FormUtils.readImage); + } + + // Selects: clear focus on change + for(const _htmlSelectElement of /** @type {NodeListOf} */(htmlRootElement.querySelectorAll("select"))) { + _htmlSelectElement.addEventListener("change", FormUtils.clearFocus); + } + + // Forms: validate on submit; optionally re-dispatch submit on click/change + for(const _htmlFormElement of /** @type {NodeListOf} */(htmlRootElement.querySelectorAll("form"))) { + _htmlFormElement.addEventListener("submit", FormUtils.checkValidity); + + if(_htmlFormElement.classList.contains(FormUtils.CLASS.formOnClick)) { + _htmlFormElement.addEventListener("click", FormUtils.dispatchSubmitEvent); + } + if(_htmlFormElement.classList.contains(FormUtils.CLASS.formOnChange)) { + _htmlFormElement.addEventListener("change", FormUtils.dispatchSubmitEvent); + } + } + + // Radio-style groups + await FormUtils.initRadios(container); + } + + /** Remove focus from the active element */ + static clearFocus() { + const activeElement = /** @type {HTMLElement|null} */ (document.activeElement); + + if(activeElement && typeof activeElement.blur === "function") { + activeElement.blur(); + } + } + + /** + * Mirror selected filename into a sibling input[name="file-path"]. + * @param {Event} event + */ + static readPath(event) { + const htmlInputElement = /** @type {HTMLInputElement} */ (event.currentTarget); + const filePathInput = /** @type {HTMLInputElement|null} */ ( + htmlInputElement.parentElement?.querySelector("input[name='file-path']") + ); + + if(!filePathInput || !htmlInputElement.files?.[0]) { + return; + } + + const value = htmlInputElement.value; + const startIndex = value.lastIndexOf(value.includes('\\') ? '\\' : '/'); + + filePathInput.value = startIndex >= 0 + ? value.substring(startIndex + 1) + : value; + } + + /** + * If the chosen file is an image, preview it into the element referenced by data-file-preview-target. + * @param {Event} event + */ + static readImage(event) { + const input = /** @type {HTMLInputElement} */ (event.currentTarget); + const file = input.files?.[0]; + + if (!file || !file.type.startsWith("image/")) return; + + const targetId = input.dataset.filePreviewTarget + const previewImg = targetId ? + /** @type {HTMLImageElement|null} */ (document.getElementById(targetId)) + : null; + + if (!previewImg) return; + + const reader = new FileReader(); + + reader.onload = (progressEvent) => { + previewImg.src = String(progressEvent.target?.result ?? ""); + }; + + reader.readAsDataURL(file); + } + + /** Dispatch a synthetic submit on the current form */ + static dispatchSubmitEvent(event) { + const htmlFormElement = /** @type {HTMLFormElement} */ (event.currentTarget); + htmlFormElement?.dispatchEvent(FormUtils.EVENTS.submit); + } + + /** Trim all text/number inputs and textareas within the form + * @param {HTMLFormElement} htmlFormElement + */ + static async trimInputs(htmlFormElement) { + for(const _htmlElement of /** @type {(HTMLInputElement|HTMLTextAreaElement)[]} */ (htmlFormElement.querySelectorAll("input[type='text'], input[type='number'], textarea"))) { + _htmlElement.value = _htmlElement.value.trim(); + } + } + + /** Validate form, optionally submit via XHR if .form-xhr is present + * @param {Event} event + * @this {HTMLFormElement} + */ + static async checkValidity(event) { + event.preventDefault(); + FormUtils.clearFocus(); + + const htmlFormElement = /** @type {HTMLFormElement} */ (event.currentTarget); + await FormUtils.trimInputs(htmlFormElement); + + if(htmlFormElement.checkValidity()) { + if(htmlFormElement.classList.contains(FormUtils.CLASS.formXHR)) { + FormUtils.ajaxRequest(htmlFormElement); + } else { + htmlFormElement.submit(); + } + } else { + htmlFormElement.reportValidity(); + } + } + + /** Submit the form via XHR and process JSON responses + * @param {HTMLFormElement} htmlFormElement + */ + static ajaxRequest(htmlFormElement) { + const xmlHttpRequest = new XMLHttpRequest(); + + xmlHttpRequest.responseType = "json"; + xmlHttpRequest.onload = () => FormUtils.ajaxResponse(xmlHttpRequest); + + xmlHttpRequest.open(htmlFormElement.method || "POST", htmlFormElement.action); + xmlHttpRequest.setRequestHeader("X-Requested-With", "XMLHttpRequest"); + xmlHttpRequest.send(new FormData(htmlFormElement)); + } + + /** + * Handle XHR load event and react to simple JSON instructions. + * Recognized format: { "::function": [ { name: "log", params: any } ] } + * @param {XMLHttpRequest} xmlHttpRequest + */ + static ajaxResponse(xmlHttpRequest) { + const { status, response } = xmlHttpRequest; + + // Treat any 2xx as success + if(status < 200 || status >= 300) { + console.error({ + status: "error", + httpStatus: status, + message: "Ajax request error" + }); + + return; + } + + if (!response || typeof response !== "object") return; + + //ToDo - Logic example, edit/expand as needed + if("::function" in response) { + for(const _fn of response["::function"]) { + const { name, params } = _fn || {}; + + switch(name) { + case "log": console.log(params); break; + default: + // unsupported action; ignore silently + break; + } + } + } + } + + // ---------- Radio helpers ---------- + + /** Initialize radio-style containers + * @param {Document|HTMLElement|DocumentFragment|null} container + */ + static initRadios(container = null) { + const htmlRootElement = container ?? document; + const radioContainerList = /** @type {NodeListOf} */ (htmlRootElement.querySelectorAll(`.${FormUtils.CLASS.radioContainer}`)); + + for(const _radioContainerElement of radioContainerList) { + if (_radioContainerElement.dataset.radiosInitialized === "1") continue; + _radioContainerElement.dataset.radiosInitialized = "1"; + + + for(const _htmlRadioInputElement of /** @type {NodeListOf} */ (_radioContainerElement.querySelectorAll(`.${FormUtils.CLASS.radioInputWrapper} input`))) { + _htmlRadioInputElement.closest(`.${FormUtils.CLASS.radioInputWrapper}`)?.classList.add(`radio-type-${_htmlRadioInputElement.type}`); + } + + _radioContainerElement.addEventListener("change", FormUtils.radioChangeListener); + // trigger initial state + _radioContainerElement.dispatchEvent(FormUtils.EVENTS.change); + } + } + + /** Update label state based on checked inputs + * @param {Event} event + */ + static radioChangeListener(event) { + const radioContainerElement = event.currentTarget; + + for(const _htmlLabelElement of /** @type {NodeListOf} */ (radioContainerElement.querySelectorAll("label"))) { + const htmlInputElement = /** @type {HTMLInputElement|null} */ (_htmlLabelElement.querySelector("input")); + + if(!htmlInputElement) continue; + + _htmlLabelElement.classList.toggle( + FormUtils.CLASS.radioChecked, + htmlInputElement.checked + ); + + if(htmlInputElement.checked === true) { + _htmlLabelElement.classList.add(FormUtils.CLASS.radioChecked); + } else { + _htmlLabelElement.classList.remove(FormUtils.CLASS.radioChecked); + } + } + } +}