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
+
+
+
+
+```
+
+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);
+ }
+ }
+ }
+}