Skip to content

Form

Fields submitted together, a submit refused while one is invalid.

Solidtype_heroui.Form is a <form>, to which HeroUI gives no classes of its own. Kobalte has no form: it is built here, above Kobalte’s fields. Every field inside it – a text field, a number field, a search field, a checkbox, a checkbox group, a radio group, a switch, a slider – tells it its name and whether it is invalid. A submit while one is invalid is refused, and the first such field, in the page’s order, is focused instead.

A field validates nothing itself. Whether it is invalid is the application’s to say, from its own state, by its is_invalid; when its error shows is the application’s too, from the field’s on_blur. The form asks the browser to validate nothing, novalidate, since a field’s input may be one Kobalte hides.

The rules are the application’s, from its signals; each error shows once its field is left.

Must be at least 8 characters with 1 uppercase and 1 number

form_usage.mlx
module S = Solidtype.Solid
module Jsx = Solidtype.Jsx
module Button = Solidtype_heroui.Button
module Description = Solidtype_heroui.Description
module Field_error = Solidtype_heroui.Field_error
module Form = Solidtype_heroui.Form
module Input = Solidtype_heroui.Input
module Label = Solidtype_heroui.Label
module Text_field = Solidtype_heroui.Text_field
external prevent_default : Dom.event -> unit = "preventDefault" [@@mel.send]
(* The form's rules, from its own state: each field's error, if any. *)
let email_error v =
match String.index_opt v '@' with
| Some at when at > 0 && String.contains_from v at '.' -> None
| Some _ | None -> Some "Please enter a valid email address"
let password_error v =
let has p = String.exists p v in
if String.length v < 8 then Some "Password must be at least 8 characters"
else if not (has (fun c -> c >= 'A' && c <= 'Z')) then
Some "Password must contain at least one uppercase letter"
else if not (has (fun c -> c >= '0' && c <= '9')) then
Some "Password must contain at least one number"
else None
let make () =
let email, set_email = S.create_signal "" in
let password, set_password = S.create_signal "" in
(* An error shows once its field is left, or the form submitted. *)
let left, set_left = S.create_signal [] in
let leave name () = S.set set_left (name :: S.get left) in
let shown name error () =
if List.exists (String.equal name) (S.get left) then error () else None
in
let email_shown = shown "email" (fun () -> email_error (S.get email)) in
let password_shown =
shown "password" (fun () -> password_error (S.get password))
in
let sent, set_sent = S.create_signal false in
<Form
class_="flex w-96 flex-col gap-4"
on_submit=(fun e ->
prevent_default e;
S.set set_sent true)
on_reset=(fun _ ->
S.set set_email "";
S.set set_password "";
S.set set_left [];
S.set set_sent false)>
<Text_field
is_required=true
name="email"
value=(fun () -> S.get email)
on_change=(S.set set_email)
on_blur=(fun _ -> leave "email" ())
is_invalid=(fun () -> Option.is_some (email_error (S.get email)))>
<Label>"Email"</Label>
<Input placeholder="john@example.com" />
<Field_error>
(fun () -> Jsx.string (Option.value (email_shown ()) ~default:""))
</Field_error>
</Text_field>
<Text_field
is_required=true
name="password"
value=(fun () -> S.get password)
on_change=(S.set set_password)
on_blur=(fun _ -> leave "password" ())
is_invalid=(fun () -> Option.is_some (password_error (S.get password)))>
<Label>"Password"</Label>
<Input placeholder="Enter your password" type_="password" />
<Description>
"Must be at least 8 characters with 1 uppercase and 1 number"
</Description>
<Field_error>
(fun () -> Jsx.string (Option.value (password_shown ()) ~default:""))
</Field_error>
</Text_field>
<div class_="flex gap-2">
<Button type_=`submit>"Submit"</Button>
<Button type_=`reset variant=`secondary>"Reset"</Button>
</div>
<p class_="text-sm text-muted">
(fun () -> Jsx.string (if S.get sent then "Submitted." else ""))
</p>
</Form>
<Form on_submit=save>
(* fields *)
<Button type_=`submit>"Save"</Button>
</Form>

validation_errors, a server’s errors by field name, makes each field it names invalid, shown by its Field_error, until the field is changed and then left, as React’s does, or until the errors are given again.

form_server_errors.mlx
module S = Solidtype.Solid
module Button = Solidtype_heroui.Button
module Field_error = Solidtype_heroui.Field_error
module Form = Solidtype_heroui.Form
module Input = Solidtype_heroui.Input
module Label = Solidtype_heroui.Label
module Text_field = Solidtype_heroui.Text_field
external prevent_default : Dom.event -> unit = "preventDefault" [@@mel.send]
let make () =
(* What a server answered, by field name: here, every name is taken. *)
let errors, set_errors = S.create_signal [] in
<Form
class_="flex w-96 flex-col gap-4"
validation_errors=(fun () -> S.get errors)
on_submit=(fun e ->
prevent_default e;
S.set set_errors [ ("username", "This username is taken.") ])>
<Text_field name="username">
<Label>"Username"</Label> <Input placeholder="jane_doe" /> <Field_error />
</Text_field>
<Button class_="self-start" type_=`submit>"Sign up"</Button>
</Form>

A client wiretype prints for an application’s routes checks a body against the routes’ own rules before it sends it, and reads a server’s refusal: each problem it finds is at a path in the body, with a sentence for that place. A field’s name is that path, so the problems are validation_errors in one List.map, and solidtype-heroui needs no codec of its own. The example board’s form to add a task, whose title the routes say is 1 to 120 characters and its note at most 200:

example/src/add_task.mlx
module S = Solidtype.Solid
module Jsx = Solidtype.Jsx
module Dialog = Solidtype_kobalte.Dialog
module Button = Solidtype_heroui.Button
module Close_button = Solidtype_heroui.Close_button
module Field_error = Solidtype_heroui.Field_error
module Form = Solidtype_heroui.Form
module Input = Solidtype_heroui.Input
module Label = Solidtype_heroui.Label
module Text_area = Solidtype_heroui.Text_area
module Text_field = Solidtype_heroui.Text_field
module Modal = Solidtype_heroui.Styles.Modal
external prevent_default : Dom.event -> unit = "preventDefault" [@@mel.send]
(* The dialog that puts a task on the board; [on_added] is told its title.
What the server finds wrong with a field is shown on that field. *)
let%component make ~(on_added : string -> unit) () =
(* HeroUI has no modal ported yet, so Kobalte's dialog wears HeroUI's
modal classes: a backdrop blurred behind it, and one that only places
it. *)
let modal = Modal.slots (Modal.options ()) in
let backdrop =
Modal.backdrop (Modal.slots (Modal.options ~variant:`blur ()))
in
let placing =
Modal.backdrop (Modal.slots (Modal.options ~variant:`transparent ()))
in
let open_, set_open = S.create_signal false in
let title, set_title = S.create_signal "" in
let note, set_note = S.create_signal "" in
let tried, set_tried = S.create_signal false in
let refused, set_refused = S.create_signal [] in
let problem, set_problem = S.create_signal "" in
let untitled () =
S.get tried && String.equal (String.trim (S.get title)) ""
in
let opened o =
S.batch (fun () ->
if not o then (
S.set set_title "";
S.set set_note "";
S.set set_tried false;
S.set set_refused [];
S.set set_problem "");
S.set set_open o)
in
let answered = function
| Ok (t : Api.task) ->
opened false;
on_added t.title
| Error (`Invalid (problems : Api.Problem.t list)) ->
S.set set_refused
(List.map (fun (p : Api.Problem.t) -> (p.at, p.message)) problems)
| Error e -> S.set set_problem (Said.error e)
in
let submit e =
prevent_default e;
S.set set_tried true;
if not (untitled ()) then
ignore
(Js.Promise.then_
(fun a -> Js.Promise.resolve (answered a))
(Api.post_api_tasks
~body:
{
title = S.get title;
note = (match S.get note with "" -> None | n -> Some n);
}
())
: unit Js.Promise.t)
in
<Dialog.Root open_=(fun () -> S.get open_) on_open_change=opened>
<Button on_click=(fun _ -> opened true)>"Add a task"</Button>
<Dialog.Portal>
<Dialog.Overlay class_=(fun () -> backdrop) />
<div class_=(fun () -> placing)>
<div class_=(fun () -> Modal.container modal)>
<Dialog.Content class_=(fun () -> Modal.dialog modal ^ " gap-5")>
<Close_button
class_=(fun () -> Modal.close_trigger modal)
aria_label="Close"
on_click=(fun _ -> opened false)
/>
<header class_=(fun () -> Modal.header modal)>
<Dialog.Title class_=(fun () -> Modal.heading modal)>
"Add a task"
</Dialog.Title>
</header>
<Form
on_submit=submit
validation_errors=(fun () -> S.get refused)
class_="flex flex-col gap-6">
<div class_=(fun () -> Modal.body modal ^ " flex flex-col gap-4")>
<Text_field
variant=`secondary
name="title"
value=(fun () -> S.get title)
on_change=(S.set set_title)
is_required=true
is_invalid=(fun () -> untitled ())>
<Label>"Title"</Label>
<Input placeholder="What is to be done" />
<Field_error>"Give the task a title."</Field_error>
</Text_field>
<Text_field
variant=`secondary
name="note"
value=(fun () -> S.get note)
on_change=(S.set set_note)>
<Label>"Note"</Label>
<Text_area placeholder="A line more about it" rows="2" />
<Field_error>"The note is too long."</Field_error>
</Text_field>
<p class_="text-sm text-danger empty:hidden" role=`alert>
(fun () -> Jsx.string (S.get problem))
</p>
</div>
<footer class_=(fun () -> Modal.footer modal)>
<Button variant=`tertiary on_click=(fun _ -> opened false)>
"Cancel"
</Button>
<Button type_=`submit>"Add"</Button>
</footer>
</Form>
</Dialog.Content>
</div>
</div>
</Dialog.Portal>
</Dialog.Root>

A body the client finds wrong is never sent: Api.post_api_tasks answers `Invalid with its problems, at the body’s own paths, title or note. A server’s refusal, `Refused, names each problem by where it is in the request, body.title, so a form that shows those on its fields takes the prefix off first:

| Error (`Refused ({ problems = Some problems; _ } : Api.refusal)) ->
S.set set_refused
(Array.to_list problems
|> List.filter_map (fun (p : Api.problem) ->
match String.split_on_char '.' p.at with
| "body" :: path -> Some (String.concat "." path, p.message)
| _ -> None))

HeroUI’s onInvalid, to choose what a refused submit focuses, and the render function.

Form renders a <form> with no classes of HeroUI’s. Its fields’ look comes from their own components.

React solidtype Type Default Description
onSubmit on_submit Dom.event -> unit Called on a submit while every field is valid
onReset on_reset Dom.event -> unit Called as it is reset
validationErrors validation_errors (string * string) list A server’s errors, by field name
action action string Where it is submitted
method method_ [ `get | `post | `dialog ] How it is submitted
aria-label aria_label string Its name
aria-labelledby aria_labelledby string The element that names it
className class_ string Its classes
validationBehavior — A field validates nothing itself: it is invalid as the application says
onInvalid — Not yet: a refused submit focuses the first invalid field

Original HeroUI documentation: Form.

HeroUI’s Form has no classes and no state of its own to compare; its test runs the same server error through the port and HeroUI’s React Form, shown, typed into and left.