Form
HeroUIFields 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.
module S = Solidtype.Solidmodule Jsx = Solidtype.Jsxmodule Button = Solidtype_heroui.Buttonmodule Description = Solidtype_heroui.Descriptionmodule Field_error = Solidtype_heroui.Field_errormodule Form = Solidtype_heroui.Formmodule Input = Solidtype_heroui.Inputmodule Label = Solidtype_heroui.Labelmodule 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>Anatomy
Section titled “Anatomy”<Form on_submit=save> (* fields *) <Button type_=`submit>"Save"</Button></Form>Examples
Section titled “Examples”Server Errors
Section titled “Server Errors”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.
module S = Solidtype.Solidmodule Button = Solidtype_heroui.Buttonmodule Field_error = Solidtype_heroui.Field_errormodule Form = Solidtype_heroui.Formmodule Input = Solidtype_heroui.Inputmodule Label = Solidtype_heroui.Labelmodule 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>From a codec
Section titled “From a codec”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:
module S = Solidtype.Solidmodule Jsx = Solidtype.Jsxmodule Dialog = Solidtype_kobalte.Dialogmodule Button = Solidtype_heroui.Buttonmodule Close_button = Solidtype_heroui.Close_buttonmodule Field_error = Solidtype_heroui.Field_errormodule Form = Solidtype_heroui.Formmodule Input = Solidtype_heroui.Inputmodule Label = Solidtype_heroui.Labelmodule Text_area = Solidtype_heroui.Text_areamodule Text_field = Solidtype_heroui.Text_fieldmodule 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))Not yet
Section titled “Not yet”HeroUI’s onInvalid, to choose what a refused submit focuses, and the
render function.
Styling Reference
Section titled “Styling Reference”Form renders a <form> with no classes of HeroUI’s. Its fields’ look comes
from their own components.
API Reference
Section titled “API Reference”| 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 |
Beside React
Section titled “Beside React”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.
