Validate what players type
Check text and number input with validators, show errors under the field, and enable a Save button only while every field is valid.
Steps
Four widgets take typed input: TextInput, NumberField, MultiLineInput and the input of a StringListEditor. They all validate the same way.
How validation works#
- A validator is a function from the input to an error
Text, ornullwhen the input is valid. - It runs after every edit and on every refresh, on the main thread. Keep it fast.
- The error shows on a line below the field.
onChangereceives the input only while it is valid, so your config never holds invalid text.- The typed text lives in the widget, not in your code. Invalid text stays in the field across refreshes until the player fixes it.
Validate a text field#
TextInput name = TextInput.builder("name")
.label(Text.translatable("mymod.name", "Profile name"))
.initial(config.profileName)
.maxLength(32)
.validator(s -> s.trim().isEmpty()
? Text.translatable("mymod.name.empty", "Enter a name")
: null)
.onChange(s -> config.profileName = s)
.build();
maxLength is a hard limit: the field accepts no more characters, so you don't need to validate length.
Validate a number#
NumberField checks the number itself. It shows these errors with no validator at all:
| Input | Error |
|---|---|
| not a number, or empty | "Not a number" |
a decimal in an ofInt field (2.5; 2.0 is accepted as 2) |
"Not a whole number" |
below range's minimum |
"Must be at least %s" |
above range's maximum |
"Must be at most %s" |
Your own validator runs last, only on numbers that passed those checks:
NumberField<Integer> port = NumberField.ofInt("port")
.label(Text.translatable("mymod.port", "Port"))
.initial(config.port)
.range(1024, 65535)
.validator(n -> n == 8080 ? Text.translatable("mymod.port.taken", "8080 is reserved") : null)
.onChange(n -> config.port = n)
.build();
Numbers are parsed with . as the decimal separator in every language, and 1e3 is accepted.
Enable Save only while the input is valid#
Keep a reference to the field and read isValid() from the button's enabled supplier. A disabled button
shows its tooltip too, which is a good place to say why:
Button create = Button.builder("create")
.label(Text.translatable("mymod.create", "Create"))
.enabled(name::isValid)
.tooltip(() -> name.isValid() ? null : Text.translatable("mymod.create.fix", "Fix the name first"))
.onPress(() -> {
store.create(name.value());
host.back();
})
.build();
With several fields, combine them: .enabled(() -> name.isValid() && port.input().isValid()).
NumberField.input() is the text field it is drawn as.
Read the input in an action#
Instead of mirroring every edit with onChange, an action can read the field when it runs:
| Widget | Current input | Valid? |
|---|---|---|
TextInput |
value(), valid or not |
isValid(), error() |
MultiLineInput |
value(), with \n line breaks |
isValid(), error() |
NumberField |
number() |
input().isValid() |
To replace the text from code, for example to clear a field after using it, call setValue("") on a
TextInput or MultiLineInput.
Validate list items#
A StringListEditor already refuses empty input and items that are in the list ("Already in the list"). Add
your own rule with validator:
StringListEditor.builder("servers")
.items(() -> config.servers)
.onAdd(config.servers::add)
.onRemove(config.servers::remove)
.inputLabel(Text.translatable("mymod.servers.add", "Server address"))
.validator(s -> s.contains(" ") ? Text.translatable("mymod.servers.space", "No spaces") : null)
.build();
Test it#
FakeHost.type(id, text) types into a field; render() shows the error:
host.type("port", "80");
assertTrue(host.render().contains("(error: Must be at least 1024)"));
Next steps#
- TextInput and NumberField: every builder option.
- State and refresh: why typed text is the exception to "the mod owns the value".