Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Fields and forms

A field owns its caret and tells the model, and a form is found by its fields.

By the end of this chapter you can put a text field on screen, bind it, filter what it accepts, wrap it in a labelled field with a validator, submit a form from a button, and take a date, a time, a colour or a one-time code.

A horizontal form on the dark theme: Name and Port labels in a column beside two fields, the Port field outlined in red with the message Ports run from 1024 to 65535 under it, and a Save button aligned with the fields

A form.horizontal with one invalid field. The message is in the field's own column, under the control.

How a field talks to the model

A field holds its own text, caret, selection and undo stack. None of those are things a model can hold, so bind= is the initial text and an override, and change= reports each new value. The field ignores the echo of its own keystroke rather than resetting the caret on every letter (ADR-0167).

An input method composes inline. The composition is drawn underlined at the caret with its converting clause highlighted, held beside the value rather than in it, so change= fires once for the accepted candidate and not once per keystroke. A password refuses to compose, because a candidate window is a second, unmasked window showing what is being typed. Committed text still arrives (ADR-0292).

Right-to-left editing is not built. A paragraph approximates bidirectional text rather than refusing it (ADR-0218).

text-input

A text-input is a single line of text in a well.

column {
  text-input bind="app.name" change="app.set-name" placeholder="Your name" max-length=40
  text-input bind="app.port" change="app.set-port" filter="digits" max-length=5
  text-input password=#true placeholder="The word that opens the doors"
  text-input value="Copied from the Red Book" read-only=#true
  text-input value="Speak, friend" disabled=#true
}
import dev.goldberry.widgets.form.textinput.TextInput;
import dev.goldberry.widgets.form.textinput.TextFilter;

new Column(
        TextInput.of(Models.observable(app, "app.name"), actions::setName)
            .placeholder("Your name")
            .maxLength(40),
        TextInput.of(Models.observable(app, "app.port"), actions::setPort)
            .filter(TextFilter.DIGITS)
            .maxLength(5),
        new TextInput().password(true).placeholder("The word that opens the doors"),
        new TextInput("Copied from the Red Book", null).readOnly(true),
        new TextInput("Speak, friend", null).disabled(true)
);

A filter rejects and never corrects: a keystroke or a paste the filter refuses leaves the field as it was. A paste past max-length is clipped, not refused. A read-only field has a caret and a selection and takes no edit. A disabled one is out of the Tab order.

Suggestions under a field are TextInput.suggesting(options) in Java. The field reports what was typed through change, is rebuilt with a list, and reports a chosen suggestion through the same change. In markup, suggestions= names a bound List<Option> the answer lands in (ADR-0367).

Attributes

AttributeTypeDefaultWhat it does
valuestring""the written text
bindpathnonethe text to follow; the initial value and an override
changeaction namenonetold the whole value after every edit
placeholderstring""shown while empty, in --gb-text-placeholder
max-lengthintegerunlimitedcharacters; 0 or less means no limit
passwordboolean#falsemasks the text, refuses copy and cut, composes nothing
read-onlyboolean#falsea caret and a selection, no edits
filternone, digits, integer, alnumnonewhat the field accepts; an unknown name is logged and the field accepts anything
suggestionspathnonea bound List<Option> shown under the field
disabledboolean#falseout of the Tab order
class, id, tooltip, context-menu, nameas on every widget

alphanumeric is accepted as a spelling of alnum.

Styling

  • CSS type text-input.
  • Parts: text-value, which takes the class placeholder while empty; text-caret; text-selection; text-composition, the rule under an open composition.
  • Pseudo-classes: :hover, :focus-visible, :disabled. Inside a field, field:invalid text-input draws the danger border.

Height 32, padding 8, radius 4, in body. The fill is --gb-surface-sunken, an alpha, so a field is one step below the page, a panel or a card alike (ADR-0168). The caret and the selection are one line tall, and the caret is --gb-caret-width wide (ADR-0253). text-align places the value in the field and the caret with it (ADR-0324).

Keyboard

One key map serves text-input, text-area and the canvas Editor (ADR-0376).

KeyDoes
Left, Rightmove the caret
Ctrl+Left, Ctrl+Rightmove by word; in a password, to the start or the end
Home, Endthe start and the end of the line
Shift with any of theseextends the selection
Ctrl+Aselects all
Backspace, Deletedelete one character; with Ctrl, a word
Ctrl+Z, Ctrl+Shift+Z, Ctrl+Yundo and redo; a typing run is one step
Ctrl+X, Ctrl+C, Ctrl+Vcut, copy and paste; a password refuses the first two
Tabmoves focus; the field does not consume it

The accelerators are on the platform’s modifier, so they are Cmd on macOS and Ctrl elsewhere. Word movement stays on Ctrl everywhere.

A click places the caret and a drag is a selection. Where the platform has a primary selection, a finished selection is published and a middle click pastes it at the pointer (ADR-0504).

Read more

text-area

A text-area is text-input with a second dimension: soft wrap, a height that grows between two row counts, and a scrollbar past the second.

column {
  text-area bind="app.bio" change="app.set-bio" rows=3 max-rows=6 placeholder="A few lines"
  text-area class="mono" gutter=#true rows=8 max-rows=8 bind="app.notes" change="app.set-notes"
  text-area fill=#true bind="doc.source" change="doc.set-source"
}
import dev.goldberry.widgets.form.textarea.TextArea;

new Column(
        TextArea.of(Models.observable(app, "app.bio"), actions::setBio)
            .rows(3, 6)
            .placeholder("A few lines"),
        TextArea.of(Models.observable(app, "app.notes"), actions::setNotes)
            .rows(8, 8)
            .gutter(true)
            .styled("mono"),
        TextArea.of(Models.observable(doc, "doc.source"), actions::setSource)
            .fill(true)
            .onEdit(actions::remember)
            .edit(pending)
);

gutter=#true numbers the lines you typed, at the positions the wrap put them. A paragraph that soft-wraps into three lines takes one number and three lines’ height, which is why a column of numbers built beside the control does not work (ADR-0331).

fill=#true makes it an editor rather than a field: it takes the height its container gives it and scrolls inside that.

onEdit and edit are Java only and are the seam a shortcut needs. change= says what the text is now and nothing about where. onEdit is told a TextEdit of text, anchor and caret after every change, caret moves included, and edit(TextEdit) offers an edit the application computed, caret and all (ADR-0332). A TextEdit is not a value a document can write.

Attributes

AttributeTypeDefaultWhat it does
valuestring""the written text
bindpathnonethe text to follow
changeaction namenonetold the whole value after every edit
placeholderstring""shown while empty
rowsinteger3the height to start at, in lines; at least 1
max-rowsinteger10, or rows if largerthe height to grow to before scrolling
max-lengthintegerunlimitedcharacters
fillboolean#falsetake the container’s height and scroll inside it
gutterboolean#falsenumber the hard lines down the left edge
read-onlyboolean#falsea caret and a selection, no edits
disabledboolean#falseout of the Tab order
class, id, tooltip, context-menu, nameas on every widget

Styling

  • CSS type text-area.
  • Parts: text-input’s four, one highlight and one composition rule per visual line, and text-area-gutter, the strip of numbers.
  • Pseudo-classes: :hover, :focus-visible, :disabled.

Minimum height 64, padding 8, radius 4. The gutter’s ink is --gb-gutter-color and its room is --gb-gutter-gap, and the column is measured in whatever font the node resolved, which is why the showcase gives a gutter class="mono". Past max-rows it draws scroll’s overlay bar over its own offset (ADR-0362).

The height is the widget’s and not the stylesheet’s, because it is a function of how many lines the text wrapped into.

Keyboard

text-input’s map, and:

KeyDoes
Enterbreaks a line
Up, Downmove a line, keeping the column
Home, Endthe start and the end of the visual line
Ctrl+Home, Ctrl+Endthe start and the end of the text
wheelthree lines of this control’s own text per notch

Read more

field

A field is a label, a control, and a message slot under it, with a validator over the control’s value.

form class="horizontal" {
  field label="Name" required=#true {
    text-input bind="signup.name" change="signup.set-name" placeholder="Meriadoc Brandybuck"
  }
  field label="Port" required=#true validator="signup.port-rule" {
    text-input bind="signup.port" change="signup.set-port" filter="digits" max-length=5
  }
  field class="actions" {
    button class="primary" press="signup.submit" "Enlist"
  }
}
import dev.goldberry.widgets.form.field.Field;
import dev.goldberry.widgets.form.form.Form;
import dev.goldberry.widgets.form.Validator;

new Form(
        new Field("Name",
                TextInput.of(Models.observable(signup, "signup.name"), actions::setName))
            .required(true),
        new Field("Port",
                TextInput.of(Models.observable(signup, "signup.port"), actions::setPort)
                    .filter(TextFilter.DIGITS))
            .required(true)
            .validate(Validator.parsing(Integer::parseInt, "A port is a number")),
        new Field("", new Button("Enlist", actions::submit).styled("primary"))
            .styled("actions")
).styled("horizontal");

The validation model

A field is silent until you leave it once, and live from then on. It says nothing while the user is still in it, however wrong the value is. Once it has complained, it re-checks on every change so the message goes the instant the value is fixed. Submitting is the third moment, and the only one that makes an unvisited field speak (ADR-0169).

A validator returns a message, not a boolean, because a field that goes red without saying why is one somebody has to guess at. required=#true is Validator.required("This field is required") in front of whatever validator= names, and and reports the first failure because the message slot is one line. Validator.of(predicate, message), minLength, matching and parsing are the built-in rules.

The field reads its value from the control’s own bind=. It walks its children one level, so a hint under the control is not what gets validated.

A click on the label focuses the control (ADR-0170).

Attributes

AttributeTypeDefaultWhat it does
labelstring""the label beside or above the control
requiredboolean#falsean asterisk on the label and a rule that refuses blank
validatorobject namenonea Validator<String> the application registered under this name
class, id, tooltip, context-menu, nameas on every widget

The children are the controls. A validator= name resolves in the Named registry, the fourth registry beside actions, icons and bindings. An application builds one with Named.strict().bind("signup.port-rule", rule) and hands it to Widgets.inflater(named, icons, models…).

Styling

  • CSS type field.
  • Parts: field-label, which takes the class required; field-body, the column holding the control and the message; field-message, in --gb-danger-text.
  • Pseudo-classes: :invalid, which field:invalid text-input and field:invalid select use for the danger border.
  • Classes: horizontal puts the label in a column beside the body; vertical takes it back inside a form.horizontal; actions is a field with no label whose body lines up with the controls.

Stacked is the default. The label column’s width is --gb-field-label-width, one token, so an application moves every form at once. A field is two boxes because the subset has no grid: a label beside a body is a row, and a message under a control is a column (ADR-0169).

Keyboard

None of its own. The controls inside it have theirs.

Read more

form

A form finds the fields in its subtree and gates a submission on their validity.

form controller="signup.form" submit="signup.enlist" {
  field label="Name" required=#true { text-input placeholder="Peregrin Took" }
  field label="Port" { text-input placeholder="8080" filter="digits" }
}
import dev.goldberry.widgets.form.form.FormController;

var controller = new FormController();

new Form(
        new Field("Name", new TextInput().placeholder("Peregrin Took")).required(true),
        new Field("Port", new TextInput().placeholder("8080").filter(TextFilter.DIGITS))
).controller(controller)
    .onSubmit(actions::enlist);

controller.submit();      // validates every field, runs onSubmit when all pass
controller.isValid();
controller.errors();      // every field's message, in order
controller.reset();       // clears every message

A FormController is what submits, because a Save button is usually outside the form. submit() makes every field check, returns whether all passed, and runs submit= when they did. submit carries nothing: bind= reads from the application’s model, so an event carrying the bound values would hand an application its own data back (ADR-0169).

The fields are anywhere in the subtree: inside rows, inside cards, inside a collapse.

Attributes

AttributeTypeDefaultWhat it does
controllerobject namenonea FormController the application registered under this name
submitaction namenonerun when a submission passes
class, id, tooltip, context-menu, nameas on every widget

Styling

  • CSS type form.
  • No parts.
  • No pseudo-classes.
  • Classes: horizontal gives every field in it a label column; form.horizontal field.vertical takes one field back.

Keyboard

None. Enter in a field does not submit. A button’s action calls FormController.submit().

Read more

code-input

A code-input is the one-time-code field: a row of single-character boxes over one string.

Six square boxes on the dark theme in two groups of three, the first three filled with 1, 2 and 3 and the rest empty

A code-input of six. The wider gap at the midpoint exists only when the length is even.

column {
  code-input length=6 type="digits" bind="app.code" change="app.set-code" complete="app.code-complete"
  code-input length=6 mask=#true
  code-input length=5 type="alnum"
}
import dev.goldberry.widgets.form.codeinput.CodeInput;
import dev.goldberry.widgets.form.codeinput.CodeType;

new Column(
        CodeInput.of(Models.observable(app, "app.code"), actions::setCode)
            .length(6)
            .onComplete(actions::codeComplete),
        new CodeInput(6, null).mask(true),
        new CodeInput(5, null).type(CodeType.ALNUM)
);

The value is a string and the boxes are a drawing. There is no caret and no per-box array: the active box is the first empty one, derived on every frame. Typing appends, a paste of 123 456 drops the spaces and fills every box at once, and complete fires on the edit that filled the last box (ADR-0273). A character the type refuses is dropped rather than rejecting the whole paste.

Attributes

AttributeTypeDefaultWhat it does
valuestring""the written code
bindpathnonethe code to follow
changeaction namenonetold the whole code after every edit
completeaction namenonetold the code when the last box fills
lengthinteger6the number of boxes; less than 1 falls back to 6
typedigits, alnumdigitswhat a box accepts; an unknown name is logged and digits are taken
maskboolean#falsedraws a dot in a filled box
disabledboolean#falseout of the Tab order
class, id, tooltip, context-menu, nameas on every widget

Styling

  • CSS type code-input.
  • Parts: code-group, one per half when the length is even; code-box, which takes the classes filled and active.
  • Pseudo-classes: :focus-visible, :hover, :disabled. code-input:focus-visible code-box.active is the ring.

Box 40 by 48, gap 8, radius 4, in title and centred. The group gap is 16 at the midpoint of an even length. The ring moves between boxes instantly and nothing travels.

Keyboard

KeyDoes
typingfills the active box and moves on
Backspace, Deleteclear the box before the ring
Escclears the code
Ctrl+Vpastes, dropping what the type refuses

Arrow keys do nothing, because there is one insertion point. Copy and cut are not built, because a masked code must not have a way out.

Read more

date-picker

A date-picker is a text-input that parses, with a calendar in a popover. The typed field is the source of truth.

date-picker bind="trip.date" change="trip.set-date" month="2026-09" \
            min="2026-09-01" max="2026-12-31" today="2026-09-18" \
            placeholder="When are you leaving?"
import java.time.LocalDate;
import java.time.YearMonth;
import dev.goldberry.widgets.form.datepicker.DatePicker;

DatePicker.of(Models.observable(trip, "trip.date"), actions::setDate, YearMonth.of(2026, 9))
    .between(LocalDate.of(2026, 9, 1), LocalDate.of(2026, 12, 31))
    .today(LocalDate.of(2026, 9, 18))
    .placeholder("When are you leaving?");

The grid writes text into the field exactly as a user would, so a value takes one path and is parsed in one place. A date outside min and max, or one the disabledDates predicate refuses, cannot be pressed in the grid and is left in the field unparsed rather than deleted. Parsing and formatting use the locale’s short form, DateFormat.of(locale), and the toolkit invents no date syntax of its own (ADR-0274).

The month is an argument and today may be null. Nothing in the catalogue reads the machine’s clock, so a picker that opened on “this month” would be deciding what month it is. In Java, onChange receives a DateSelection, range(true) selects a pair, and the bound value may be a LocalDate, a DateSelection or text. From markup, change carries the formatted text.

Attributes

AttributeTypeDefaultWhat it does
valuestring""the written text
bindpathnonethe value to follow
changeaction namenonetold the formatted date
placeholderstring""shown while empty
rangeboolean#falseselect a pair and report it as one value
monthISO year-monthmin’s month, else 1970-01the month the grid opens on; a malformed one is logged and ignored
todayISO datenonethe day marked as today
min, maxISO datenonethe reachable range, in the field and in the grid
disabledboolean#falseout of the Tab order
class, id, tooltip, context-menu, nameas on every widget

disabledDates, format and locale are Java only.

Styling

  • CSS type date-picker.
  • Parts: date-picker text-input, the field; picker-toggle, the chevron; picker-panel, the popover, holding a calendar.
  • Pseudo-classes: :checked while the panel is open, so date-picker:checked picker-toggle turns the chevron; :disabled.

Popup radius 12, padding 8, day cell 32 square with a full radius on the selected day.

Keyboard

KeyDoes
typingedits the field, which is the value
Alt+Downopens the grid
Esccloses the grid and reverts
Left, Righta day
Up, Downa week
PgUp, PgDna month; with Shift, a year
Home, Endthe start and the end of the week
Enter, Spacechoose the day

Read more

time-picker

A time-picker is the same control as the date picker with wheels in the popover instead of a grid.

time-picker bind="trip.time" change="trip.set-time" min="06:00" max="22:00" \
            precision="minutes" placeholder="Boarding time"
import java.time.LocalTime;
import dev.goldberry.widgets.form.timepicker.TimePicker;
import dev.goldberry.widgets.form.timepicker.TimePrecision;

TimePicker.of(Models.observable(trip, "trip.time"), actions::setTime)
    .between(LocalTime.of(6, 0), LocalTime.of(22, 0))
    .precision(TimePrecision.MINUTES)
    .placeholder("Boarding time");

Each column is a wheel: five rows centred on the value, wrapping at both ends, so 58 59 00 01 02 says what comes next. The wheels report on every turn, because there is no unchosen state for an hour (ADR-0275). precision= is which columns the picker has, and the default format follows it. In Java, onChange receives a LocalTime, or null when the field is cleared. From markup, change carries the formatted text.

A range that wraps past midnight is refused. It is two ranges, and the disabledTimes predicate is how to say so.

Attributes

AttributeTypeDefaultWhat it does
valuestring""the written text
bindpathnonethe value to follow
changeaction namenonetold the formatted time
placeholderstring""shown while empty
precisionhours, minutes, secondsminuteswhich wheels the picker has; an unknown name is logged and minutes are taken
fallbackISO time00:00where the wheels open when the field is empty
min, maxISO timenonethe reachable range
disabledboolean#falseout of the Tab order
class, id, tooltip, context-menu, nameas on every widget

disabledTimes and format are Java only.

Styling

  • CSS type time-picker.
  • Parts: time-picker text-input; picker-toggle; picker-panel; time-columns; time-column; time-cell, which takes the classes selected and roving.
  • Pseudo-classes: :checked while the panel is open; :disabled.

Column width 48, cell height 32 with radius 4, five rows a column with the middle one filled with --gb-accent, gap 4 between columns.

Keyboard

KeyDoes
typingedits the field, which is the value
Alt+Downopens the wheels
Esccloses them and reverts
Up, Downturn the wheel the focus is on
Left, Rightchoose which wheel
Home, Endthe first and the last wheel
Enter, Spacecommit

The arrows split by axis where a calendar’s do not.

Read more

color-picker

A color-picker is a swatch that opens a board: a saturation and value plane, a hue ramp, an optional alpha ramp, a hex field and preset swatches. The hex field is the source of truth.

row {
  color-picker bind="paint.colour" change="paint.set-colour"
  color-picker alpha=#true value="#bf616a80"
}
import dev.goldberry.widgets.form.colorpicker.ColorPicker;

new Row(
        ColorPicker.of(Models.observable(paint, "paint.colour"), actions::setColour)
            .presets(List.of(0xFFBF616A, 0xFFA3BE8C, 0xFF81A1C1)),
        new ColorPicker("#bf616a80", null).alpha(true)
);

The plane, the ramps and the presets all write hex into the field, so a value takes one path and is parsed in one place. The plane is HSV, because a saturation and value plane is HSV, and a colour with no saturation keeps its hue beside the hex so the hue ramp does not swing to red at the left edge (ADR-0276).

alpha is off by default and refuses in both directions: a picker with no way to change alpha must not report one, so a translucent value is gated to opaque. In Java, onChange receives the colour as 0xAARRGGBB and the bound value may be an Integer or hex text. From markup, change carries the hex.

Attributes

AttributeTypeDefaultWhat it does
valuehex string""the written colour
bindpathnonethe value to follow
changeaction namenonetold the hex
alphaboolean#falseshows the alpha ramp and allows translucent values
disabledboolean#falseout of the Tab order
class, id, tooltip, context-menu, nameas on every widget

presets is Java only: a List<Integer> of 0xAARRGGBB.

Styling

  • CSS type color-picker.
  • Parts: color-swatch, the closed control, and color-swatch.preset in the board; color-board; color-plane; color-ramp; color-presets.
  • Pseudo-classes: :focus-visible on color-swatch and color-plane; :disabled.

Swatch 24 with radius 4, plane 200 by 160, ramps 12 tall, preset swatch 20 with gap 4. Everything in the board is painted rather than styled, because the subset has no gradient.

Keyboard

KeyDoes
Space, Enter on the swatchopens the board
arrows on the planemove the cursor one step
Shift and arrowsten steps
Space, Enter on a presetpicks it

Read more