Skip to content
Theme

Forms

A form in Limn is a Column of fields. There is no form object to bind to and no validation framework to configure: a field is a widget, a validation rule is a listener, and submitting is a method call.

This page builds the form in the picture, one piece at a time.

The form below, rendered by Limn in its light palette.The form below, rendered by Limn in its light palette.
The form below, rendered by Limn in its light palette.

Every field on that form has the same shape, so it is worth one helper. STRETCH is what makes the control fill the column instead of shrinking to fit its own text.

static Widget field(String caption, Widget control) {
Column group = new Column();
group.gap(6).crossAlignment(Flex.CrossAlignment.STRETCH);
group.add(new Label(caption).setMuted(true));
group.add(control);
return group;
}

Use it for every control that takes a value: text fields, password fields, combo boxes and spinners all behave the same way here.

Control For
TextField one line of text, with a placeholder and an optional leading icon
PasswordField the same, masked
TextArea several lines, with its own scrollbars
SearchField a text field with search affordances
ComboBox one choice from a list
Spinner a number with steppers
Slider a number on a range
Checkbox a boolean, as a box or as a switch
RadioButton one of several, grouped by a ButtonGroup

Each reports changes through a listener rather than an event object:

TextField email = new TextField();
email.setPlaceholder("ada@example.com");
email.onChange(text -> model.setEmail(text));

A field carries a validation state (NONE, ERROR, WARNING, SUCCESS, INFO) which recolours its border. Setting it is your decision, made whenever you like: on change, on blur, or only when the user presses the submit button.

static void validate(TextField email, Label message) {
email.onChange(text -> {
boolean ok = text.matches("[^@\\s]+@[^@\\s]+\\.[^@\\s]+");
email.setValidation(ok ? TextField.Validation.SUCCESS : TextField.Validation.ERROR);
message.setText(ok ? "" : "Enter an address like ada@example.com");
});
}

Two details in there earn their place. The message label exists whether or not it has text, so the form does not jump by a line the first time a field fails. And the rule runs on change, which means the error clears itself as soon as the user fixes it. A form that only revalidates on submit makes people press the button to find out whether they are done.

Put the buttons in a Row with a spacer in front of them, and they sit against the trailing edge whatever the form’s width turns out to be.

static Widget actions() {
Row actions = new Row();
actions.gap(12).crossAlignment(Flex.CrossAlignment.CENTER);
actions.add(Expanded.spacer(1));
actions.add(new Button("Cancel").setSecondary(true));
actions.add(new Button("Create account"));
return actions;
}

The primary action is the plain Button; setSecondary(true) gives the quieter one. Keep one primary per form; if two buttons are both primary, neither is.

Everything above, assembled:

public static Widget form() {
TextField name = new TextField();
name.setText("Ada Lovelace");
TextField email = new TextField();
email.setText("ada@example");
Label emailMessage = new Label("Enter an address like ada@example.com");
emailMessage.setColor(Theme.current().danger);
validate(email, emailMessage);
email.setValidation(TextField.Validation.ERROR);
PasswordField password = new PasswordField();
password.setText("correct horse battery");
ComboBox plan = new ComboBox(List.of("Personal", "Team", "Enterprise"));
plan.setSelectedIndex(1);
Checkbox updates = new Checkbox(Checkbox.Variant.SWITCH, "Email me release notes");
updates.setChecked(true);
Column emailGroup = new Column();
emailGroup.gap(6).crossAlignment(Flex.CrossAlignment.STRETCH);
emailGroup.add(email);
emailGroup.add(emailMessage);
Column form = new Column();
form.gap(18).crossAlignment(Flex.CrossAlignment.STRETCH);
form.add(new Label("Create your account").setRole(Label.Role.TITLE));
form.add(field("Full name", name));
form.add(field("Email", emailGroup));
form.add(field("Password", password));
form.add(field("Plan", plan));
form.add(updates);
form.add(Separator.horizontal());
form.add(actions());
return form;
}

A form long enough to scroll needs two things said, and neither is the default:

public static Widget scrolling(Widget form) {
ScrollView scroll = new ScrollView(new Padding(Insets.all(16), form), false, true);
scroll.setBarLayout(ScrollGutters.Layout.RESERVED);
return scroll;
}

The padding goes inside the scroll view. A viewport clips at its own edge, and a focused control paints its ring outside its own box, so a field flush against that edge loses the ring that says it is focused. Padding around the scroll view does not help: the clip travels with the viewport, not with the box around it.

The bar takes a strip of its own rather than floating. Every row of a form ends in something the reader is aiming at, and an overlay bar is drawn over the viewport with no knowledge that the content has a margin, so it lands on the trailing edge of the fields. RESERVED narrows the content instead, and nothing is ever painted under a bar.

There is no submit event. Read the values off the widgets you are holding and call your own code:

submit.onAction(() -> {
Account account = new Account(name.text(), email.text(), plan.selectedItem());
Ui.work(progress -> accounts.create(account))
.onSuccess(created -> router.showAccount(created))
.onFailure(error -> banner.setText(error.getMessage()))
.start();
});

That Ui.work(…) is the important part: creating the account is a network call, and running it directly in the button handler would freeze the window until it came back. See Background work.

Tab moves between fields in tree order, so the order you add them is the order people move through them, and Escape closes a Dialog. Focus is drawn as a ring outside the control, so it never sits on top of the field’s own border, and the ring’s colour is solved against the accent it surrounds, so you do not have to check it yourself.

A form does not submit on Enter by default. If you want that, subclass the field and handle the key: onKeyEvent is the hook, and consuming the event stops it going any further:

class SubmitOnEnter extends TextField {
private final Runnable submit;
SubmitOnEnter(Runnable submit) {
this.submit = submit;
}
@Override
protected void onKeyEvent(KeyEvent event) {
if (event.isPressed() && event.key() == Keys.ENTER) {
submit.run();
event.consume();
return;
}
super.onKeyEvent(event);
}
}