Forms

JWeb's Form DSL provides Java methods for form elements with HTML5 validation. Handle form events with state or submit to server handlers.

Overview

Create forms with the form() element and various input elements. Attribute shortcuts like name, type, and validation rules are plain arguments.

form(action("/submit"), method("POST"),
    input(type("text"), name("name"), required()),
    input(type("email"), name("email"), required()),
    button(type("submit"), "Submit")
)

Basic Form

Create forms with action and method attributes.

form(action("/submit"), method("POST"),
    div(
        label(for_("name"), "Name"),
        input(type("text"), id("name"), name("name"),
            placeholder("Enter your name"), required())
    ),
    div(
        label(for_("email"), "Email"),
        input(type("email"), id("email"), name("email"),
            placeholder("you@example.com"), required())
    ),
    button(type("submit"), "Submit")
)

Form Structure

Group form fields with fieldset and legend.

form(action("/register"), method("POST"),
    fieldset(
        legend("Personal Information"),
        div(
            label(for_("fname"), "First Name"),
            input(type("text"), id("fname"), name("firstName"))
        ),
        div(
            label(for_("lname"), "Last Name"),
            input(type("text"), id("lname"), name("lastName"))
        )
    ),
    fieldset(
        legend("Account"),
        div(
            label(for_("uname"), "Username"),
            input(type("text"), id("uname"), name("username"))
        )
    ),
    button(type("submit"), "Register")
)

Input Types

All HTML5 input types are supported.

// Text inputs
input(type("text"), name("name"))
input(type("email"), name("email"))
input(type("password"), name("password"))
input(type("tel"), name("phone"))
input(type("url"), name("website"))
input(type("search"), name("query"))

// Number inputs
input(type("number"), name("age"), attrs().min("0").max("120"))
input(type("range"), name("volume"), attrs().min("0").max("100"))

// Date/time inputs
input(type("date"), name("birthdate"))
input(type("time"), name("appointment"))
input(type("datetime-local"), name("meeting"))

// Other inputs
input(type("color"), name("favorite"))
input(type("file"), name("avatar"), attrs().accept("image/*"))
input(type("hidden"), name("userId"), value("123"))

Textarea

textarea(name("bio"), placeholder("Tell us about yourself..."),
    attrs().rows(4).cols(50).maxlength(500))

A Form Is a Record

form(SomeRecord.class) renders the whole form: one labelled field per record component, the control type read from the Java type, the CSRF hidden field taken from the current request, and a submit button. The same record validates the submission on the server, so the rules are written once.

public record Contact(
    @Form.Required String name,
    @Form.Required @Form.Email String email,
    @Form.Required @Form.Multiline(rows = 4) String message) {}

// Render
form(Contact.class)
    .action("/contact")
    .submit("Send message")

Component Type to Control

The Java type picks the control; the hints refine it.

String                       -> <input type="text">
@Form.Email String           -> <input type="email">   + email validation
@Form.Password String        -> <input type="password">
@Form.Multiline String       -> <textarea>
int, long, Integer, Long     -> <input type="number">
double, float, BigDecimal    -> <input type="number" step="any">
boolean, Boolean             -> <input type="checkbox">
an enum                      -> <select> over its constants
LocalDate / LocalTime        -> <input type="date"> / type="time">
LocalDateTime                -> <input type="datetime-local">
UploadedFile                 -> <input type="file">, form becomes multipart

Overriding a Field

field(name, f -> ...) changes how a field is presented — label, placeholder, help text, control type, rows. The rules (required, email, length) stay on the record, so the browser and the server can never disagree about them.

record Contact(
    @Form.Required String name,
    @Form.Required @Form.Email String email,
    @Form.Required @Form.Multiline(rows = 4) String message) {}

form(Contact.class)
    .action("/contact")
    .field("email", f -> f.label("Your email")
                          .placeholder("you@example.com")
                          .autocomplete("email"))
    .field("message", f -> f.rows(6).help("Markdown is fine."))
    .submit("Send message")

Binding and Errors

Form.bind reads the submitted values, validates them against the same record, and hands back the value or the messages. Passing the result to errors(...) re-renders the form with the user's input, per-field messages, aria-invalid on the controls, and a summary.

record Contact(
    @Form.Required String name,
    @Form.Required @Form.Email String email,
    @Form.Required @Form.Multiline(rows = 4) String message) {}

class MessageStore { void save(Contact c) {} }
MessageStore messages = new MessageStore();

app.post("/contact", req -> {
    Form.Bound<Contact> submitted = Form.bind(Contact.class, req);
    if (!submitted.ok()) {
        return form(Contact.class)
            .action("/contact")
            .errors(submitted)          // messages + the values as typed
            .submit("Send message");
    }
    Contact contact = submitted.value();
    messages.save(contact);
    return p("Thanks!");
});

Progressive Submission

swapForm posts over fetch and swaps the returned fragment into a target; the plain action() keeps working with JavaScript off.

record Contact(
    @Form.Required String name,
    @Form.Required @Form.Email String email,
    @Form.Required @Form.Multiline(rows = 4) String message) {}

form(Contact.class)
    .id("contact-form")
    .action("/contact/submit")                    // no-JS fallback
    .swapForm("/contact/submit", "#form-status")  // progressive swap
    .submit("Send Message")

Styling

The form emits stable class names — jweb-form, jweb-field, jweb-label, jweb-control, jweb-help, jweb-error, jweb-errors, jweb-submit. Form.styles() is a ready-made stylesheet for them; drop it in the head once and override what you like.

Cls JWEB_SUBMIT = cls("jweb-submit");

CSSValue BRAND = hex("#6366f1");

head(
    style(Form.styles()),
    style(stylesheet()
        .rule(JWEB_SUBMIT, style().backgroundColor(BRAND))
        .build())
)

Selects Without a Record

Outside a record form, a select is elements all the way down.

List<String> skillsList = List.of("Java", "SQL", "Docker");

select(name("car"), id("car"),
    optgroup(attrs().label("Swedish Cars"),
        option(value("volvo"), "Volvo"),
        option(value("saab"), "Saab")
    ),
    optgroup(attrs().label("German Cars"),
        option(value("mercedes"), "Mercedes"),
        option(value("audi"), "Audi")
    )
)

// Multi-select
select(name("skills"), attrs().multiple(),
    each(skillsList, s -> option(value(s), s))
)
The record is the single source of truth: it renders the form, states the rules, and is what you get back from Form.bind.

Select Dropdowns

Create select elements with options.

// Basic select
select(name("country"),
    option(value(""), "Select a country"),
    option(value("us"), "United States"),
    option(value("uk"), "United Kingdom"),
    option(value("ca"), "Canada")
)

// With selected option
select(name("status"),
    option(value("active"), selected(), "Active"),
    option(value("inactive"), "Inactive")
)

// Option groups
select(name("car"),
    optgroup(attrs().label("Swedish Cars"),
        option(value("volvo"), "Volvo"),
        option(value("saab"), "Saab")
    ),
    optgroup(attrs().label("German Cars"),
        option(value("mercedes"), "Mercedes"),
        option(value("audi"), "Audi")
    )
)

Dynamic Options

record Country(String code, String name) {}
List<Country> countries = List.of(new Country("us", "United States"), new Country("ca", "Canada"));

select(name("country"),
    option(value(""), "Select..."),
    each(countries, c ->
        option(value(c.code()), c.name())
    )
)

Checkboxes

Single and multiple checkboxes.

// Single checkbox
div(
    input(type("checkbox"), id("agree"), name("agree"), required()),
    label(for_("agree"), "I agree to the terms")
)

// Checkbox group
fieldset(
    legend("Select interests"),
    div(
        input(type("checkbox"), id("tech"), name("interests"), value("tech")),
        label(for_("tech"), "Technology")
    ),
    div(
        input(type("checkbox"), id("sports"), name("interests"), value("sports")),
        label(for_("sports"), "Sports")
    ),
    div(
        input(type("checkbox"), id("music"), name("interests"), value("music")),
        label(for_("music"), "Music")
    )
)

Radio Buttons

fieldset(
    legend("Select plan"),
    div(
        input(type("radio"), id("free"), name("plan"), value("free")),
        label(for_("free"), "Free")
    ),
    div(
        input(type("radio"), id("pro"), name("plan"), value("pro"), checked()),
        label(for_("pro"), "Pro - $9/month")
    ),
    div(
        input(type("radio"), id("enterprise"), name("plan"), value("enterprise")),
        label(for_("enterprise"), "Enterprise - $99/month")
    )
)

HTML5 Validation

Use built-in validation attributes.

// Required field
input(type("text"), name("name"), required())

// Email validation
input(type("email"), name("email"), required())

// Min/max length
input(type("text"), name("username"),
    attrs().minlength(3).maxlength(20))

// Number range
input(type("number"), name("age"),
    attrs().min("18").max("100"))

// Pattern validation
input(type("text"), name("phone"),
    attrs().pattern("[0-9]{3}-[0-9]{3}-[0-9]{4}")
        .title("Format: 123-456-7890"))

Custom Error Messages

input(type("email"), name("email"), required(),
    attrs().title("Please enter a valid email address"))

// Custom validation message via JavaScript
input(type("text"), name("username"),
    attrs().pattern("[a-z0-9_]+"),
    data("error", "Username can only contain lowercase letters, numbers, and underscores"))

Server-Side Validation

class UserService { void register(String email, String password) {} }
UserService userService = new UserService();
Element registerForm(List<String> errors) { return div(); }

app.post("/register", req -> {
    String email = req.formParam("email");
    String password = req.formParam("password");

    List<String> errors = new ArrayList<>();

    if (email == null || !email.contains("@")) {
        errors.add("Invalid email address");
    }
    if (password == null || password.length() < 8) {
        errors.add("Password must be at least 8 characters");
    }

    if (!errors.isEmpty()) {
        return registerForm(errors);
    }

    userService.register(email, password);
    return Response.redirect("/login");
});

Event Handlers

Handle form events with state.

State<String> searchTerm = useState("");
State<String> email = useState("");
void validateEmail(String value) {}
void showHint() {}
void hideHint() {}

// Input event (fires on every keystroke)
input(
    type("text"),
    value(searchTerm.get()),
    onInput(e -> searchTerm.set(e.value())))

// Change event (fires on blur)
input(
    type("email"),
    value(email.get()),
    onChange(e -> {
        email.set(e.value());
        validateEmail(e.value());
    }))

// Focus and blur
input(
    type("text"),
    onFocus(e -> showHint()),
    onBlur(e -> hideHint()))

Form Submit

import java.util.concurrent.CompletableFuture;

State<String> name = useState("");
State<String> email = useState("");
State<Boolean> loading = useState(false);
CompletableFuture<Void> submitForm(String n, String e) { return CompletableFuture.completedFuture(null); }

form(onSubmit(e -> {
    e.preventDefault();
    loading.set(true);

    // Submit data
    submitForm(name.get(), email.get())
        .thenRun(() -> loading.set(false));
}),
    input(type("text"), onInput(e -> name.set(e.value()))),
    input(type("email"), onInput(e -> email.set(e.value()))),
    button(type("submit"), attrs().disabled(loading.get()),
        loading.get() ? "Submitting..." : "Submit")
)

File Upload

Handle file uploads with validation and storage.

import jweb.FileUpload;
import jweb.UploadedFile;
import java.io.IOException;
import java.nio.file.Path;

app.post("/upload", req -> {
    UploadedFile file = FileUpload.getFile(req, "document");

    if (file.isEmpty()) {
        return Response.badRequest("No file provided");
    }

    try {
        Path saved = file.saveTo(Path.of("uploads"));    // saveTo throws IOException
        return Response.json(Map.of("path", saved.toString()));
    } catch (IOException e) {
        return Response.error(500, "Could not store the upload");
    }
});

Upload Form

form(action("/upload"), method("post"), attrs().enctype("multipart/form-data"),

    input(type("file"), name("document")),
    button(type("submit"), "Upload")
)

File Properties

import java.io.InputStream;

UploadedFile file = FileUpload.getFile(req, "document");

// Check if file was uploaded
if (file.isEmpty()) { /* no file was uploaded */ }

// File info
String name = file.getFilename();     // "report.pdf"
String type = file.getContentType();  // "application/pdf"
long size = file.getSize();           // bytes

// Get content
byte[] bytes = file.getBytes();
InputStream stream = file.getInputStream();

// Type checks
if (file.isImage()) { /* PNG, JPG, GIF */ }
if (file.hasExtension("pdf", "doc")) { /* document */ }

Validation

UploadedFile file = FileUpload.getFile(req, "document");

var validation = FileUpload.validate(file)
    .required()                     // must be present
    .maxSizeMB(10)                 // max 10MB
    .imagesOnly();                 // PNG, JPG, GIF only

Object result = validation.isValid()
    ? Response.ok()
    : Response.badRequest(validation.getFirstError());

// Or with specific extensions
FileUpload.validate(file)
    .required()
    .maxSizeMB(5)
    .allowedExtensions("pdf", "doc", "docx")

Multiple Files

import java.nio.file.Path;

// HTML
input(type("file"), name("images"), attrs().multiple().accept("image/*"))

// Handler
List<UploadedFile> images = FileUpload.getFiles(req, "images");

for (UploadedFile image : images) {
    var v = FileUpload.validate(image).maxSizeMB(5).imagesOnly();
    if (v.isValid()) {
        String filename = UUID.randomUUID() + "_" + image.getFilename();
        image.saveTo(Path.of("uploads/gallery"), filename);
    }
}

Save Options

import java.nio.file.Path;

UploadedFile file = FileUpload.getFile(req, "document");

// Save to directory (generates a unique name)
file.saveTo(Path.of("uploads"));

// Save with custom name
file.saveTo(Path.of("uploads"), "custom-name.pdf");

// Save preserving the original filename
file.saveWithOriginalName(Path.of("uploads"));
Configure max file size in application.yaml: spring.servlet.multipart.max-file-size