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.


A field is a caption, a control and the line under it
Section titled “A field is a caption, a control and the line under it”Every field on that form has the same shape: a caption above the control, the control at the
column’s width, and a line under it that says how to fill the field or what is wrong with it.
That shape is Field:
static Field emailField(TextField email) { return new Field("Email", email) .setHelperText("The receipt goes here") .setRequired(true);}Field takes any control: a text field, a combo box, a date picker, a spinner, a slider, a
check box, or a row of two controls that make one value, an amount and its currency. The
caption is drawn in the label font, muted, and it is the control’s name: a screen reader says
“Email, text field, required” rather than “text field”. A caption that merely sits above a
control names nothing, and Accessibility says why.
setRequired(true) puts an asterisk after the caption, in the palette’s danger colour, and
tells each system’s screen reader that the field is required, in that system’s own words. It
validates nothing: whether an empty required field is an error, and when to say so, is yours.
The helper text is the line under the control until a message takes its place. It is shown
whether or not the field has the keyboard, which a placeholder is not, and a screen reader hears
it as the field’s description. A field whose control sits inside something else, a field beside
a switch or in a sized box, names the control and lays out the wrapper:
new Field(caption, control, wrapper). With a null caption a check box keeps its own words and
gains the line and the states. A caption beside its control is a Grid(2) of a Label bound
with setLabelFor and a field built with no caption.
The controls
Section titled “The controls”| Control | For |
|---|---|
TextField |
one line of text, with a placeholder, an optional leading icon, and an optional mask |
PasswordField |
the same, masked |
TextArea |
several lines, with its own scrollbars |
SearchField |
a text field with search affordances |
ComboBox |
one choice from a list, or typed into and filtered as it is |
Spinner |
a number with steppers |
DateField |
a date, a time, or both, typed into segments the language orders |
DatePicker |
the same field with a calendar behind a button; ofRange() for a period |
CalendarView |
a month grid on its own, for a screen that shows one |
Slider |
a number on a range |
RangeSlider |
a lower and an upper bound on a range, two thumbs that never cross |
Checkbox |
a boolean, as a box or as a switch |
RadioButton |
one of several, grouped by a ButtonGroup |
ColorPickerButton |
a colour, shown as a chip that opens a ColorPicker in a dialog |
ColorPicker |
the whole picker in the form: a field and a hue ramp, a hex field, and channels in RGB, HSV or CMYK |
Each has two channels, and they answer two different questions. The fluent onX slot is the
handler: the application’s one response to the user operating the widget. It never runs
for a value the application wrote itself, so a form that fills its fields from a record cannot
echo back into its own model. The validation rule below is a handler, and the form sets the
email field’s starting state explicitly for exactly that reason: setText is the application’s
write, and the rule did not run for it.
Anything that must mirror a widget, wherever the change came from — a character counter, a detail pane, a two-way binding — watches it instead. A watcher hears every change with its origin. The count under the name field is one:
static Field counted(Field field, TextField text, int limit) { field.setHelperText(text.text().length() + " / " + limit); text.observeChanges((widget, change) -> { if (change.aspect() == Change.Aspect.TEXT) { field.setHelperText(text.text().length() + " / " + limit); } }); return field;}The helper line is written once from the text field and then follows it: the setText that
fills the form, and every keystroke after it, both reach the watcher. observeChanges hands back a
Subscription, and cancelling it is the one way to stop watching; a widget’s watchers
otherwise live as long as the widget does.
A handler slot holds one handler: registering a second one throws, and null clears it. Two
parties that both want to hear the same widget are both watching, and watching has no such limit.
A field that types to a pattern
Section titled “A field that types to a pattern”A code with a fixed shape, a serial number, a document number, a postal code, is a TextField
with a mask:
TextField serial = new TextField().setPlaceholder("Serial number").setMask("AAA-0000-**");0 takes a digit, A a letter and * any character; every other character of the pattern is a
literal the field writes by itself when the next position is filled, and the literals that end
the pattern arrive with its last position (00 kg shows 12 kg). Characters a position does
not take are passed over, so pasting KLM-4217-X9 and pasting KLM4217X9 give the same text,
and Backspace after a literal takes the character before it. An edit in the middle leaves every
literal where the pattern has it, and a field whose positions are all filled takes nothing more,
so a correction is made by selecting what to replace and typing over it: typing or pasting over
a selection overwrites its positions one by one and moves nothing, so with 12 of
ABC-1234-XY selected, 5 gives ABC-5234-XY with the 2 still selected, and 6 then
ABC-5634-XY. A character the next selected position does not take is refused, and a paste
fills the selected positions in order, leaving those it does not reach selected and dropping
what is left of it where a character of the value follows; at the end of the value it goes on
into the free positions, so pasting 9990123 over the last 5 of +1 (415) 5 gives
+1 (415) 999-0123. A selection of the whole value (every filled position, with or without the
literals around them, as Ctrl+A gives) is replaced as in any field: typing or pasting over it
starts a new value. On a pattern that mixes
letters, digits and any characters, like the one above, any other edit moves the characters after it,
and one that would land where it is not taken would be lost, so such an edit is refused and the
field does nothing: typing Q after the B of ABC-1234-X, or Backspace in the digits of
ABC-1234-XY, changes nothing, while Backspace in the digits of ABC-1234 or a 5 typed after
the 3 of ABC-1234-X (the 4 moving into an any position) is made. A refused Cut copies
nothing, and neither does a Cut of literals alone, which the field writes back at once. A user may also type the literals:
under 'DE'00 0000 the D shows, then the E, then the digits, and typing DE89 or 89
gives the same value. While the field has the keyboard the positions still to fill show after the
text as a guide, KLM-42__-__, and the caret stands where the next character will appear, after
the DE of an empty 'DE'00 0000; without it, an empty field shows its placeholder. text() is what shows, unmaskedText() the characters typed into
the positions, and isMaskComplete() whether every one is filled, which is the check a form makes
before it accepts the value. A screen reader hears the text with its literals, but not the
pattern: AAA-0000-** is syntax, not words, so say what the field expects in your user’s
language, as the helper text of the field around it: “Three letters, four digits and two more”.
It is shown with or without the keyboard, where the guide shows only with it, and a reader
hears it as the field’s description.
A masked field lays its text out left to right in every language: its groups, the stretches
between literals, run left to right in the order they are written, whatever the script. In an
Arabic or a Hebrew form a card number reads 4111 1111 1111 1112, or ٤١١١ ١١١١ ١١١١ ١١١٢ in
Arabic-Indic digits, and a phone +1 (415) 555-0123, starting at the left edge of the box, with
the guide after it, while the field’s icons and trailing button stay on the sides the layout
puts them. Inside a group each script keeps its own direction, so a group of Hebrew letters
reads right to left within its place. One shape is left to the bidirectional algorithm: a
quoted literal of right-to-left letters that touches a group of them, with no space or
punctuation between, is read with it as one right-to-left run (under AAA'ש'AAA the second
group stands left of the first), so keep a space or a mark of punctuation between such a label
and its group.
Text between single quotes is literal, so a pattern can hold a 0, an A or a * the field
writes rather than fills: +'0' 000 shows +0 123. Two single quotes are one apostrophe, inside
quotes and out. It is the quoting SimpleDateFormat uses, and a pattern that opens a quote
without closing it is refused.
A digit is never lost to a literal that happens to be the same digit. Under
+55 (00) 00000-0000, setText("51987654321") keeps the area code 51 and shows
+55 (51) 98765-4321, and +'0' 000 takes 012 as three digits, +0 012; set either from its
formatted text and it reads the same. The field tells a formatted text by the literals no position
would take, the +, a space, a parenthesis. A pattern whose literals are only characters its
positions take, such as '55'00000, cannot be told apart, and a value that begins with them is
read as carrying them: set such a field from its formatted text.
No pattern of any country ships with the toolkit: write the one your form needs. Numbers and
amounts are a Spinner, which follows the user’s language.
A range
Section titled “A range”A RangeSlider holds two values, a lower and an upper, on one track:
RangeSlider price = new RangeSlider(0, 1000).setStep(10).setMinimumGap(50);price.setValues(200, 600);price.onCommit((lower, upper) -> filter(lower, upper));Each thumb stops at the other, or at the minimum gap from it. Each is a Tab stop of its own and
moves by the arrows, Page Up and Page Down, Home and End, within what the other leaves it; a press
on the track takes the nearer thumb. onChange hears every step of a drag and onCommit the
range the user let go at. A screen reader hears two sliders, Minimum and Maximum, each with the
bounds the other leaves it.
Choosing from many
Section titled “Choosing from many”A combo box that only opens is right for a handful of choices. For a “Customer” field among forty
thousand, the user types a few letters and picks, and that is one call: setEditable(true).
static ComboBox<Customer> customerField(List<Customer> customers) { ComboBox<Customer> customer = new ComboBox<>(customers, Customer::name).setEditable(true); customer.onSelect(() -> System.out.println("Bill to " + customer.selectedItem().city())); return customer;}The items are your objects. ComboBox<T> holds whatever the application picks from, a record,
an entity, an enum, and the function in the constructor says how each one reads. onSelect and
selectedItem() hand back the object itself, so nothing is looked up again by its text.
Typing filters. The list opens on the first keystroke and shows the items whose text contains
what was typed, ignoring case and accents, so “sao” finds “São Paulo Alimentos”, with the ones that
start with it first. The first row is highlighted and the field is not completed in place: Down
and Up walk the list, Enter picks the highlighted row, Escape closes it and a second Escape puts
the selected item’s text back. setFilter((item, text) -> …) replaces the rule when “contains” is
not what a field means, a code that must match from its start, say.
Leaving the field decides. Tab picks the item whose text the field holds, whatever its case or accents. A text that names no item is put back to the selected one, and a screen reader is told it was not accepted. A field that should keep such a text, a city that may be new or a tag, says how a text becomes a value; an emptied field then clears it.
static ComboBox<String> cityField(List<String> known) { return new ComboBox<>(known).setEditable(true).setFreeText(text -> text.strip());}A search instead of a list. When the items live in a database, the combo asks for them as the
user types. setSearch takes the text and answers later; Ui.async runs the lookup off the thread
that paints. The combo waits 200 ms after the last keystroke before asking, shows “Loading…” in
the list meanwhile, drops an answer for a text the user has already typed past, and says “No
results” when there are none. A screen reader hears how many were found.
static ComboBox<Customer> customerSearch(CustomerDirectory directory) { return new ComboBox<Customer>(Customer::name) .setSearch(text -> Ui.async(() -> directory.find(text, 50)));}What a screen reader gets. The keyboard stays in the field while the list is up: the reader stands on the text field, named by its caption, and hears the highlighted option as its cursor moves, “Alps, 1 of 6”, then “Atlas, 1 of 2” as the next letter narrows the list. Enter reads the field again with the item it now holds. The field says it filters a list and which list, the shape a browser gives an ARIA combo box, which is what keeps Orca from reading the list twice.
Choosing several. A report filter wants “Open, Invoiced and Shipped” at once, and that is one
more call: setSelectionMode(SelectionMode.MULTI), the words a ListView uses. The list stays
down while items are checked, with a click, Space or Enter, and Ctrl+A (Cmd+A) checks every one.
The closed field shows a chip per item, in the list’s order, as many as fit on its line, then a
count of the rest, “+2”. onSelect hears every change, and selectedItems() answers in the list’s
order.
static ComboBox<Status> statusFilter(List<Status> statuses, Report report) { ComboBox<Status> status = new ComboBox<>(statuses, Status::label) .setSelectionMode(SelectionMode.MULTI) .setItemIcon(Status::icon) .setPlaceholder("Any status"); status.onSelect(() -> report.filterBy(status.selectedItems())); return status;}An editable combo that holds many keeps its text as a filter only: typing narrows the list and hides
the chips, Enter checks the highlighted row and empties the text, and the chips come back. A text
that names an item is never committed by leaving the field. setChipRenderer draws a chip with a
widget of your own, which is drawn and never operated; setItemIcon puts an icon before an item’s
text in the list, in a chip and in the field of a combo holding one.
A screen reader hears the count and every name as the field’s value, “3 selected: Open, Invoiced, Shipped”, the ones behind the “+N” included, and in the list whether each option is selected.
Nothing chosen. A combo holding one item always holds one, unless it is told otherwise:
setEmptySelectionAllowed(true) puts a first option in the list that stands for nothing, and
choosing it makes selectedItem() null. It reads as your text function answers for null, and
shows what your icon function answers for it, so it is worded as the items are. A hint that should
fill a field reading nothing, without being a value of its own, is setPlaceholder: it shows only
while the field would otherwise be blank, and an empty option that has words wins over it.
static ComboBox<Status> statusField(List<Status> statuses) { // Asked for null as well: that is the empty option, which reads "No status". return new ComboBox<>(statuses, s -> s == null ? "No status" : s.label()) .setEmptySelectionAllowed(true);}Dates and times
Section titled “Dates and times”A date is the one field a form cannot be written without and the one a text field is wrong for. Limn has three classes for it, and four shapes come out of them: a field types a date, a picker is that field with a calendar behind a button, and either of them carries a clock as well.
How much of the date you want is one call. new DateField() starts at the day and
DateField.ofTime() at the minute; setGranularity(…) picks the fine end — YEAR, MONTH,
DAY, HOUR, MINUTE, SECOND — and the field drops or grows the segments it lost or gained,
separators included. MONTH makes the picker’s popup a month chooser and YEAR a year chooser,
each terminal: a pick there is the value. A picker at HOUR or finer puts a time row under the
grid, and Tab walks the card from top to bottom, the header, the grid and that row, and round
again; the popup opens on the grid, so its first Tab reaches the row. A click on the time row
sends the keys there, and a click on the header or the grid gives them back to the control
clicked. CalendarView takes its own level as a
CalendarView.View, since a grid of months is still a grid and a grid of hours is not a thing.


static Widget<?> shapes() { Column column = new Column(); column.gap(14).crossAlignment(Flex.CrossAlignment.STRETCH);
DateField typed = new DateField(); // a date typed.setDate(LocalDate.of(2026, 9, 9));
DateField clock = DateField.ofTime(); // a time of day clock.setTime(LocalTime.of(14, 30));
DatePicker picker = new DatePicker(); // a date, with a calendar picker.setDate(LocalDate.of(2026, 9, 9));
DatePicker moment = new DatePicker() // both, with a calendar .setGranularity(DateField.Granularity.MINUTE); moment.setDateTime(LocalDateTime.of(2026, 9, 9, 18, 0));
column.add(new Field("Invoice date", typed)); column.add(new Field("Reminder", clock)); column.add(new Field("Delivery", picker)); column.add(new Field("Appointment", moment)); return column;}The value is always ISO. Everything these widgets hand out and take in is a LocalDate, a
LocalTime or a LocalDateTime, so an application stores what it asked for and never writes a
branch about calendars. What is drawn is a separate axis: the month names, the year number and
the length of a month come from the calendar system resolved for the widget’s language, which is
the Gregorian one everywhere until a locale carries a u-ca extension. A Thai user driving a
Buddhist calendar picks a day out of a grid headed with a year 543 greater, and the application
gets the ISO date.
The segments and the separators are the language’s own. The same field reads day, month, year
in Portuguese, month, day, year in American English and year, month, day in Japanese, because the
order comes from the locale’s own short pattern rather than from a format string in the
application. A two-digit year in that pattern is widened to four, because the order and the
separators are what the locale genuinely owns and the width is not. A two-digit year a person
types or pastes is not refused: it resolves into the hundred years starting eighty before today
by the field’s clock, so in 2026 26 is 2026 and 85 is 1985. setTwoDigitYearWindow(n) moves
that window, and REFUSE_TWO_DIGIT_YEARS turns the guess off — the year is then left blank and the
field stays incomplete, rather than quietly meaning the year 26. A calendar whose years carry an
era is never windowed: Reiwa 8 is the whole year, and it is drawn and typed at its own width.
The header climbs. Clicking the month name shows the twelve months of that year; clicking again
shows a block of twenty-four years. Each pick descends one step, and nothing is chosen until a day
is. That is what makes a date of birth reachable, and picker.calendar().setView(CalendarView.View.YEARS)
opens a picker straight onto the years for exactly that case.
Typing beats clicking, and both work. Up and Down adjust the segment the caret is in; Left and
Right move between segments; digits fill the current segment and roll on to the next, so
31122026 commits the last day of 2026 without a separator being typed, and a screen reader is
told which segment the caret rolled on to. Ctrl/Cmd+V parses what is on the clipboard: the
locale’s own order first, and an ISO date (2026-12-31) whatever the locale, which is what a date
out of a spreadsheet or a database usually is. Ctrl/Cmd+C copies the value as shown. A date
and a time pasted together fill both halves, and a twelve-hour time is read with the language’s
own words for its half of the day (p.m., 오후, 下午), so a field’s own copy pastes back whole.
Alt+Down opens the calendar and Alt+Up or Esc
closes it, which is the combo box’s idiom and what a reader is told the field can do.
In the grid, Page Up and Page Down page a month and Shift with either pages a year; in a range, Shift with them extends the band rather than moving the cursor alone. Escape in a chooser comes back down one level at a time — years to months to days — and Escape on the day grid closes a picker’s popup.
A period
Section titled “A period”static Widget<?> period() { DatePicker period = DatePicker.ofRange(); period.setRange(new DateRange(LocalDate.of(2026, 9, 14), LocalDate.of(2026, 9, 25))); period.onSelect(() -> System.out.println("period now " + period.range())); return new Field("Reporting period", period);}Two fields and one grid: the first click in the calendar anchors the period, the second closes it,
and the days between are drawn as a band. range() answers null until both ends are filled — a
period with one end is not a period, and is not published as one. Ends typed in the wrong
order are put in order a whole period at a time: months typed 06/2026 then 03/2026 run from
1 March to 30 June. The period reads as one line at the start of its box, the two dates and the
dash between them, whatever the box’s width; the room left over is after the second date.
Which days may be picked
Section titled “Which days may be picked”static CalendarView rules() { CalendarView calendar = new CalendarView(); calendar.setVisibleMonth(LocalDate.of(2026, 9, 9)); calendar.setMinDate(LocalDate.of(2026, 9, 2)); calendar.setDateFilter(day -> day.getDayOfWeek() != DayOfWeek.SATURDAY && day.getDayOfWeek() != DayOfWeek.SUNDAY); calendar.setShowWeekNumbers(true); calendar.setDayMarks(day -> HOLIDAYS.contains(day) ? DayMark.of(Theme.current().danger(), I18nString.literal("holiday")) : null); calendar.onSelect(() -> System.out.println("picked " + calendar.selectedDate())); return calendar;}setMinDate, setMaxDate and setDateFilter exist on the field, on the grid and on the picker,
which fans them out to both of its parts. The two halves enforce them at different moments, and
the difference is deliberate: the grid refuses the click — the day is drawn disabled and
carries no verb, so a reader is told plainly that it cannot be chosen — while the field holds
what was typed and marks itself invalid with a message saying which rule was broken. A field that
snapped a typed date to the nearest legal one would be throwing away what somebody wrote and
telling them nothing. A field of months or years is in range when any day of the period it names
is, the rule the chooser offers a cell by.
The keyboard stops on a refused day rather than skipping it. Skipping makes a month with scattered rules feel as if the arrow key were broken, and it hides the shape of the rule; stopping lets somebody walk the month and hear which days are out.
The filter runs once per painted cell, so it has to be cheap and it has to be pure; a filter that
queries a database is a filter that stalls a frame. setDayMarks decorates days with a dot and,
when it is given one, a phrase that joins what a screen reader says about that day — a dot alone
reaches everyone who looks and nobody who listens.
What a screen reader gets
Section titled “What a screen reader gets”The grid is published as a table: six rows of seven cells under a row of column headers, which
are the same four roles a Table uses and are mapped on all three platforms. A day is named with
the whole date and not the bare number, because a cell heard on its own has to say what it is, and
it carries its position as the day of its month — “15 of 30” — which is the count that means
something about a date. The field is a group of spin buttons, one per editable segment, each
with its own name and its own range: the caret is in one segment at a time, and a single text field
publishing 31/12/2026 would give a reader no way to say which part that is. A segment nobody has
filled says so in words rather than reading out its minimum.
A caption bound to a picker names the field, not the group around it, because the field is where the keyboard lands; a range picker keeps the caption on the group and names its two fields “Start date” and “End date”.
A day’s name also carries whatever setDayMarks added to it. A refused day stops the cursor and
withholds the verb; each reader says it is out of reach in words of its own, so do not write one
reader’s phrasing into your interface expecting it to match. Up on an empty segment fills it from
today, not from the segment’s minimum. The month chooser names the month on show, and the paging
buttons are named for the view they page. Not every reader speaks a day’s position; see
What readers say.
The week starts where the language’s region starts it, and a language named without a
region, de or fr, is read for the region it most likely means rather than the United States.
CalendarView.setFirstDayOfWeek fixes the day (a picker’s grid is picker.calendar()), and
setShowWeekNumbers(true) numbers the rows in the language’s own numbering, from that day.
Reading right to left, the grid mirrors and the field does not. A grid is columns in reading order, so the first day of the week moves to the edge reading starts from and Left and Right swap with it. A date is a run of numbers, and a run of numbers keeps its own left-to-right order inside a right-to-left line; what moves there is which side of the box the run sits against.
Validation
Section titled “Validation”A field carries a validation state, a Validation of NONE, ERROR, WARNING, SUCCESS or
INFO, and a message to go with it. Setting them 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, Field field) { email.onChange(text -> { if (text.matches("[^@\\s]+@[^@\\s]+\\.[^@\\s]+")) { field.setValidation(Validation.SUCCESS); } else { field.setValidation(Validation.ERROR, "Enter an address like ada@example.com"); } });}The state colours the control’s border where the control draws one (a text field, a text area,
a combo box, a spinner, a date field or picker) and the message takes the line under the
control, in the state’s colour, with a mark before it so the state never rests on colour alone.
clearValidation() gives the border and the line back.
Three details earn their place. The line is reserved from the first helper text or the first
state on, so the form does not jump by a line the first time a field fails, or back when it is
fixed. The message is the field’s description, the way the caption is its name, so a screen
reader says “Email, text field, invalid, enter an address like…” instead of leaving the reason
as a sentence somewhere else on the screen; only ERROR is invalid to a reader, since a field
that passed must not be announced as failing. 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.
A date field and a date picker check what they hold themselves, a day out of range or one your
filter refuses. While one refuses its day, the field around it shows the date’s own reason in the
error colour, and that is what a screen reader hears: ahead of the helper text, and ahead of an
INFO or SUCCESS message of yours, which would otherwise say “looks good” over a day still
refused. An ERROR or WARNING message you set still takes the line, your own judgement of the
day; the reason comes back when it goes. A message set with NONE is not shown, so it counts as
none, and a refused day keeps the border red whatever state you set.
Actions
Section titled “Actions”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").setStyle(Button.Style.SECONDARY)); actions.add(new Button("Create account")); return actions;}The primary action is the plain Button; setStyle(Button.Style.SECONDARY) gives the quieter
one, and TERTIARY a quieter one still, with no fill until the pointer is over it. Keep one
primary per form; if two buttons are both primary, neither is. A button that deletes or discards
takes setDestructive(true) at whichever style it has, which draws it in the palette’s danger
colour.
The whole form
Section titled “The whole form”Everything above, assembled:
public static Widget<?> form() { TextField name = new TextField(); name.setText("Ada Lovelace");
TextField email = new TextField(); email.setText("ada@example"); Field emailField = emailField(email); validate(email, emailField); // setText above was the application's write, and a handler runs for the user alone, so // the rule in validate did not run: the starting state is set here, explicitly. emailField.setValidation(Validation.ERROR, "Enter an address like ada@example.com");
PasswordField password = new PasswordField(); password.setText("correct horse battery");
ComboBox<String> 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 form = new Column(); form.gap(18).crossAlignment(Flex.CrossAlignment.STRETCH); form.add(new Label("Create your account").setRole(Label.Role.TITLE)); form.add(counted(new Field("Full name", name).setRequired(true), name, 40)); form.add(emailField); form.add(new Field("Password", password).setHelperText("At least 12 characters")); form.add(new Field("Plan", plan)); form.add(updates); form.add(Separator.horizontal()); form.add(actions()); return form;}When it scrolls
Section titled “When it scrolls”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 on the axis it
scrolls once the content is long enough to scroll there, and a focused control paints its ring
outside its own box, so a field flush against the top or the bottom loses the ring that says it
is focused. Padding around the scroll view does not help there: the clip travels with the
viewport, not with the box around it. The sides are different, because nothing scrolls
sideways: the view lets a ring through there by the reach the theme gives it, or by
paintOverflow(points) when something draws farther, though never into a strip reserved for the
bar. So are the top and the bottom of a form short enough to fit, where nothing scrolls either.
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.
Submitting
Section titled “Submitting”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.
Keyboard and focus
Section titled “Keyboard and focus”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 caption takes an access letter: an & before a letter in a Field’s caption, a bound
Label’s text or a button’s text, new Field("&Email", email), lets Alt and that letter bring the
keyboard to the field or press the button, on Windows and Linux. The letter is in the translated
text, so each language picks its own; Access letters
has the rules.
A form does not submit on Enter by default. If you want that, give the field an onSubmit
handler: it runs when the user presses Enter, with the text, and without one Enter is left to
whatever else answers it, such as a dialog’s default button:
password.onSubmit(text -> signIn());