Skip to main content

field — one labelled input

UiField is a single labelled value: a label, an optional hint, an optional error, and one control whose kind is chosen by fieldType. It is the only node that produces a submittable input, and it does so only when editable is true — otherwise it renders the value as plain text.

Fields normally sit in a form's fields list or a detail, but they are ordinary nodes: drop them anywhere in a layout tree and, as long as they end up inside a <form> element, they are collected with the rest of the payload.

Live — six field types plus a read-only one ("SKU", no editable) and one carrying a validationError. Toggling "Active" fires its onChange trigger, which reports the new value without submitting anything.

Fields​

FieldTypeMeaning
idStringNode id. Becomes the wrapper's DOM id and the control's name — so it is the key in the submitted payload. The control itself gets <id>__input.
labelStringThe <label> text. Required in practice.
fieldTypeFieldTypeWhich control to render. See the table below.
valueObjectCurrent value. Rendered as text when not editable.
editablebooleanDefaults to false (read-only span). true renders a real input.
requiredbooleanAppends a * marker to the label.
placeholderStringPlaceholder text. Emitted for TEXT, REFERENCE and any unrecognised type.
hintStringSmall helper text below the control.
iconStringLeading in-field icon token. Decorative, and only rendered when editable. See icons.
trailingUiActionAction rendered on the control's row, right of the input — a Browse… next to a path field, an Encrypt next to a key field. Editable fields only.
validationErrorStringPer-field error message; also puts the wrapper into its error style.
optionsList<Option>Choices for SELECT / MULTISELECT. Each Option has value and label.
minStringLower bound, verbatim min attribute. Numeric, date and time types.
maxStringUpper bound, verbatim max attribute.
stepStringStep granularity, verbatim step attribute (e.g. "0.01").
submitOnEnterbooleanTEXTAREA only: Enter submits the surrounding form, Shift+Enter inserts a newline.
submitOnChangebooleanAny change to the value submits the surrounding form.
onChangeUiTriggerTrigger fired on change, carrying the surrounding form's values as payload. Preferred over submitOnChange when only part of the UI should react.
acceptStringFILE only: the HTML accept filter ("image/*", ".pdf,.docx").
multiplebooleanFILE only: allow selecting more than one file.
expandedbooleanSELECT / MULTISELECT only: show every option at once — radio buttons for SELECT, checkboxes for MULTISELECT. Set with .asRadio() / .asCheckboxes(). The submitted value keeps its shape.
editorHeightstringRICHTEXT only: the height of the whole editor, toolbar included, as a CSS length ("320px", "20rem", "40vh"). The toolbar stays, the text scrolls. Set with .editorHeight("320px"); anything but a length is refused.
fillbooleanRICHTEXT only: the field fills the height of its flex parent, toolbar fixed, text scrolling. Set with .fill().
orderablebooleanExpanded MULTISELECT only: checked options lead the list with move-up/down buttons, and the list is submitted in the order shown. Set with .orderable(), which implies .asCheckboxes(). The buttons need the SPA EventBus or JavaFX.
cssClassStringExtra CSS class on the field wrapper.

Field types​

fieldTypeRenders asExtra fields that apply
TEXT<input type="text">placeholder, icon
TEXTAREA<textarea rows="4">submitOnEnter
RICHTEXTan editable area with a formatting toolbar, and a hidden input carrying the HTMLplaceholder — the value is an HTML string, submitted as a string under the field id like a TEXTAREA's. See Rich text below.
PASSWORD<input type="password"> + eye toggleplaceholder — the built-in toggle flips the input to plain text and back; the value never leaves the field
NUMBER<input type="number">min, max, step
CURRENCY<input type="number">min, max, step (use "0.01")
PERCENT<input type="number">min, max, step
DATE<input type="date">min, max, step — yyyy-MM-dd
DATETIME<input type="datetime-local">min, max, step — yyyy-MM-ddTHH:mm
TIME<input type="time">min, max — HH:mm; step in seconds, or .minutes(15): the field carries a datalist of exactly those times, which is what makes the browser's picker offer only them (its own minute list ignores step), and a value typed in between is rounded to the nearest one. UiField.time(id, label, "08:30"), or a LocalTime
BOOLEAN<input type="checkbox">value (truthy = checked)
SELECT<select>, or radio buttons when expandedoptions
MULTISELECT<select multiple>, or checkboxes when expandedoptions, orderable; value may be a list or a comma-separated string
FILE<input type="file">accept, multiple
REFERENCE<input type="text">placeholder, icon — a semantic marker for a foreign-key value; the renderer treats it like TEXT
HIDDEN<input type="hidden"> — no wrapper, no labelvalue only — submitted whether or not the field is editable

PASSWORD builds the reveal toggle in: the eye button (wired by the event bus) switches the input between password and text, so an admin can check what is in the field before saving. Factory: UiField.password(id, label, value).

HIDDEN carries a value the user never sees — a record id, a version for optimistic locking, the context the server needs back on submit. Nothing is drawn: the input stands alone with the field id as both DOM id and name, and a detail leaves it out. Factory: UiField.hidden(id, value). This is a different thing from .hidden() on any node, which only takes a visible field out of the layout for a while.

Radio buttons and checkboxes​

A choice with few options often reads better with every option on screen. .asRadio() turns a SELECT into a group of radio buttons, .asCheckboxes() a MULTISELECT into a group of checkboxes. The value keeps its shape — one string, or a list of strings — so a handler that reads a dropdown reads the group the same way.

One difference to plan for: a radio group can have nothing chosen. When value is null or matches no option, no radio is checked and the field submits null (a native form post sends no key at all). A dropdown in the browser never gets there, because the browser selects its first option when none is marked. Set a value if the server needs one, or treat null as "not chosen". Likewise a checkbox group with nothing checked submits [] over the SPA, and no key in a native form post.

Which options start checked or selected follows one rule on every renderer, for dropdowns and groups alike: for a MULTISELECT the option's value is in value (a list or array, or a comma-separated string with each part trimmed — "a," is a and the empty value; blank means none), for a SELECT it equals value as text. Values are compared as JavaScript prints them, so 0 selects "0", 2.0 selects "2", and null selects nothing — not even an option whose value is "". A null entry in options is skipped; an option without a value is drawn and submits "".

.orderable() goes one step further for a list whose order matters — a preference ranking, fallback models tried top to bottom. The checked options come first, in the order of value, the rest follow in option order; each checked row carries move-up and move-down buttons, and the list comes back in the order shown. Checking an option appends it to the end of the checked ones; unchecking returns it to its place among the unchecked ones — exactly where a re-render from the server puts it, so a form that answers each change with a fresh render does not shuffle rows under the user's cursor. Reordering counts as a change: onChange and submitOnChange fire once the move clicks pause, not once per step, and not at all when a tick or a submit reports the order first. The move buttons need the SPA EventBus (or the JavaFX client) and show only inside the part of the page a bus drives; on a page rendered without one they are not shown, and the order is the one the server rendered.

submitOnChange and onChange work with every type but HIDDEN; icon with every single-line control — not with HIDDEN or a radio / checkbox group. CURRENCY and PERCENT are semantic labels only: the renderer emits the same number input as NUMBER, so format the display value yourself for the read-only case.

Rich text​

RICHTEXT is formatted text. The value is an HTML string, and it comes back as a string under the field id exactly like a TEXTAREA's — the server stores and renders HTML, nothing else changes.

UiField.richtext("notes", "Notes", "<p>Ships in <b>two sizes</b>.</p>")
.asEditable().asRequired().placeholder("Formatted notes");

What the field gives the user:

Toolbarbold, italic, underline, bulleted list, numbered list, link, quote, remove formatting — and the usual shortcuts (Ctrl/⌘-B, -I, -U)
Pastingreduced to that same vocabulary on the client: script, style, link, meta, iframe and form controls are dropped with their content, other unknown tags are unwrapped, every attribute is dropped but a safe href on a link and src/alt/width/height on an image. Plain text becomes paragraphs.
Imagesan image on the clipboard (a screenshot, a copied picture) is embedded as a data: URL, scaled to at most 1280px on its longer side; a click on an image shows a corner handle that resizes it, written as width/height attributes; dragging moves it
onChange, submitOnChangeas on a textarea: a change is reported when the editor loses focus
Read-onlythe HTML shown as formatted text

A MERGE of value replaces the content, whether or not the editor has focus — a quick action or a "Use this text" button can push new HTML into the field while the user is in it. A merge of anything else (a validationError, say) keeps what the user typed, as for every field.

Height. Left alone, the editor grows with its content, and a long text takes the toolbar out of view with it. A reply to a long HTML mail, say, leaves the toolbar out of reach. Bound the height and the toolbar stays while only the text scrolls:

// A fixed height for the whole editor, toolbar included
UiField.richtext("reply", "Reply", quoted).asEditable().editorHeight("320px");

// All the height the flex parent gives it: a dialog body between a fixed
// header and fixed buttons
UiField.richtext("reply", "Reply", quoted).asEditable().fill();

fill() makes the field a flex item (flex: 1 1 auto; min-height: 0) and hands the room on to the editor box. It fills only what it is given, so every container above it has to be a flex column that gives it room, with min-height: 0 where one would otherwise grow to its content.

Every value is sanitised before it is shown — read-only and in the editor, in both renderers — with the same allowlist a paste goes through: the browser uses sanitizeRichText(), the server templates ai.mindconnect.ui.html.RichTextSanitizer. A stored value that holds a script, a handler or a javascript: link, however it is spelled, is drawn without them. Still clean what you store: run RichTextSanitizer.sanitize(html) on what a form submits, so the value in your database is the value on the page.

TIME is the browser's own time picker: UiField.time("from", "From", LocalTime.of(8, 30)) renders <input type="time"> with the value as HH:mm; min, max and step apply as on a date field; .minutes(5), .minutes(15) sets the step in a readable way. The field then carries a <datalist> of exactly those times — the browser's own picker lists every minute whatever step says, and only the datalist makes it offer the stepped ones — and a value typed in between — 09:07 at quarter hours — is rounded to the nearest one, 09:00, when the field changes, so the form sends what the picker would have chosen.

Building one​

// The factories pick the fieldType; the fluent setters do the rest.
UiField.text("name", "Name", null).asRequired().asEditable()
.placeholder("e.g. Widget").hint("Shown in the product list.");

UiField.textarea("desc", "Description", null).asEditable().submitOnEnter();

UiField.number("price", "Price", 19.0).asEditable().min("0").step("0.01");

UiField.date("launch", "Launch date", "2026-07-06").asEditable()
.range("2026-01-01", "2026-12-31");

UiField.select("category", "Category", "tools", List.of(
UiField.Option.of("tools", "Tools"),
UiField.Option.of("toys", "Toys"))).asEditable();

UiField.multiselect("tags", "Tags", null, List.of(
UiField.Option.of("new", "New"),
UiField.Option.of("sale", "Sale"))).asEditable();

// Every option on screen: radio buttons, checkboxes, a ranked list.
UiField.select("size", "Size", "m", sizes).asEditable().asRadio();
UiField.multiselect("tags", "Tags", List.of("new"), tags).asEditable().asCheckboxes();
UiField.multiselect("fallbacks", "Fallback models", List.of("sonnet", "opus"), models)
.asEditable().orderable();

UiField.bool("active", "Active", true).asEditable()
.onChange(UiTrigger.invoke("toggleActive"));

UiField.file("avatar", "Avatar").accept("image/*").multiple()
.onChange(UiTrigger.upload("/api/avatar"));

UiField.reference("owner", "Owner", "u-42").asEditable();

// Submitted with the form, never shown:
UiField.hidden("orderId", 4711);

// Read-only, and rejected-submit rendering:
UiField.text("sku", "SKU", "WGT-0042"); // editable = false
UiField.text("name", "Name", "").asEditable().error("Name is required.");
UiField.text("q", "Search", null).asEditable().editableIf(canEdit);

Notes​

id is the payload key. The name attribute keeps the model id verbatim, so {"name": "Widget", "price": "19.0"} falls out of a form whose fields are name and price. The DOM id of the control is suffixed with __input so the wrapper can keep the canonical id for patch targeting — patch the field id, not the input id.

editable defaults to false. This trips people up: a field built with UiField.text(...) and dropped into a form renders as static text and never reaches the server. Call .asEditable() (or .editableIf(condition)) on every input you actually want filled in. UiField.file(...) is the one factory that sets it for you, and a HIDDEN field ignores the flag — it always submits.

required is a marker, not validation. It adds the asterisk and nothing else — no required attribute, no client-side check. Validate on the server and send the message back in validationError, with a summary in the form's formError.

onChange beats submitOnChange for partial updates. submitOnChange posts the entire form; onChange dispatches its own trigger with the form's values folded in as payload, so one field can drive a PATCH (no round-trip) or an INVOKE handler. Both markers can be present — the bus prefers the trigger.

File fields versus the drop zone. A FILE field is a single inline picker; pair it with UiTrigger.upload(url) on onChange to send the files as multipart/form-data. When you want a drop target with a prompt and a hint, reach for upload instead.

See also​

  • form — the container that submits these fields.
  • fieldgroup — a titled <fieldset> around a set of fields.
  • upload — drag-and-drop alternative to a FILE field.
  • Forms — layout, validation and round-trip patterns.
  • Triggers & actions — what an onChange can do.