From 094574e36ff1b8dba3c0ebce88a03ead03205277 Mon Sep 17 00:00:00 2001 From: Vincent Balat Date: Tue, 24 Feb 2026 11:37:55 +0100 Subject: [PATCH 01/10] 4.2.0 --- opam | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opam b/opam index 3224ab79..60e033be 100644 --- a/opam +++ b/opam @@ -1,6 +1,6 @@ opam-version: "2.0" name: "ocsigen-toolkit" -version: "4.1.0" +version: "4.2.0" maintainer: "dev@ocsigen.org" synopsis: "Reusable UI components for Eliom applications (client only, or client-server)" description: "The Ocsigen Toolkit is a set of user interface widgets that facilitate the development of Eliom applications." From e72049c3152386e2e56cc07b2761ffb0b8448d88 Mon Sep 17 00:00:00 2001 From: Vincent Balat Date: Fri, 27 Feb 2026 10:50:57 +0100 Subject: [PATCH 02/10] Ot_form: add reactive form widgets Add shared reactive form widgets adapted from w_forms.eliom: - Reactive inputs (reactify_input, reactive_input, textarea, reactive_textarea) - Disableable button, radio buttons with reactive selection - Checkboxes with position/style options and reactive signals - Input validation tools with graceful invalid display (after blur) - Enter key binding helpers (lwt_bind_input_enter, lwt_bound_input_enter) Contribution by Be Sport --- css/ot_form.css | 131 ++++++++ ocsigen-toolkit.install | 1 + src/widgets/ot_form.eliom | 598 +++++++++++++++++++++++++++++++++++-- src/widgets/ot_form.eliomi | 240 ++++++++++++++- 4 files changed, 932 insertions(+), 38 deletions(-) create mode 100644 css/ot_form.css diff --git a/css/ot_form.css b/css/ot_form.css new file mode 100644 index 00000000..5c2b59e6 --- /dev/null +++ b/css/ot_form.css @@ -0,0 +1,131 @@ +/* ================================================================ */ +/* Checkbox */ +/* ================================================================ */ + +/* The label element has both a desktop and a mobile style class, + e.g. ot-checkbox-box-desktop + ot-checkbox-toggle-mobile. + We only style desktop classes here; apps should add media queries + to switch to mobile styles at their chosen breakpoint. + + DOM structure: + label.ot-checkbox.ot-checkbox-{style}-desktop + input.ot-checkbox-input[type=checkbox] (hidden) + span.ot-checkbox-label + span.ot-checkbox-decoration + span.ot-checkbox-sub-decoration + span (text content) +*/ + +.ot-checkbox { + display: inline-flex; + align-items: center; + cursor: pointer; + gap: 0.5em; +} + +.ot-checkbox-label { + display: inline-flex; + align-items: center; + gap: 0.4em; +} + +.ot-checkbox > .ot-checkbox-input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +/* --- Box style -------------------------------------------------- */ + +.ot-checkbox-box-desktop .ot-checkbox-decoration { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.2em; + height: 1.2em; + border: 2px solid #888; + border-radius: 3px; + background: white; + transition: background-color 0.15s, border-color 0.15s; +} + +.ot-checkbox-box-desktop .ot-checkbox-sub-decoration { + width: 0.35em; + height: 0.65em; + border: solid transparent; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + margin-bottom: 0.1em; +} + +.ot-checkbox-box-desktop > .ot-checkbox-input:checked + .ot-checkbox-label .ot-checkbox-decoration { + background-color: #4a90d9; + border-color: #4a90d9; +} + +.ot-checkbox-box-desktop > .ot-checkbox-input:checked + .ot-checkbox-label .ot-checkbox-sub-decoration { + border-color: white; +} + +/* --- Toggle style ----------------------------------------------- */ + +.ot-checkbox-toggle-desktop .ot-checkbox-decoration { + display: inline-block; + width: 2.4em; + height: 1.3em; + border-radius: 0.65em; + background-color: #ccc; + position: relative; + transition: background-color 0.2s; +} + +.ot-checkbox-toggle-desktop .ot-checkbox-sub-decoration { + display: block; + position: absolute; + width: 1.05em; + height: 1.05em; + border-radius: 50%; + background: white; + top: 0.125em; + left: 0.125em; + transition: transform 0.2s; +} + +.ot-checkbox-toggle-desktop > .ot-checkbox-input:checked + .ot-checkbox-label .ot-checkbox-decoration { + background-color: #4a90d9; +} + +.ot-checkbox-toggle-desktop > .ot-checkbox-input:checked + .ot-checkbox-label .ot-checkbox-sub-decoration { + transform: translateX(1.1em); +} + +/* --- Bullet style ----------------------------------------------- */ + +.ot-checkbox-bullet-desktop .ot-checkbox-decoration { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.2em; + height: 1.2em; + border: 2px solid #888; + border-radius: 50%; + background: white; + transition: border-color 0.15s; +} + +.ot-checkbox-bullet-desktop .ot-checkbox-sub-decoration { + width: 0.6em; + height: 0.6em; + border-radius: 50%; + background-color: transparent; + transition: background-color 0.15s; +} + +.ot-checkbox-bullet-desktop > .ot-checkbox-input:checked + .ot-checkbox-label .ot-checkbox-decoration { + border-color: #4a90d9; +} + +.ot-checkbox-bullet-desktop > .ot-checkbox-input:checked + .ot-checkbox-label .ot-checkbox-sub-decoration { + background-color: #4a90d9; +} diff --git a/ocsigen-toolkit.install b/ocsigen-toolkit.install index 83421da4..f79e6bcd 100644 --- a/ocsigen-toolkit.install +++ b/ocsigen-toolkit.install @@ -12,4 +12,5 @@ share: [ "css/ot_color_picker.css" {"css/ot_color_picker.css"} "css/ot_pull_to_refresh.css" {"css/ot_pull_to_refresh.css"} "css/ot_tongue.css" {"css/ot_tongue.css"} + "css/ot_form.css" {"css/ot_form.css"} ] diff --git a/src/widgets/ot_form.eliom b/src/widgets/ot_form.eliom index 6ed7e66d..98ed09cf 100644 --- a/src/widgets/ot_form.eliom +++ b/src/widgets/ot_form.eliom @@ -1,6 +1,8 @@ (* Ocsigen * http://www.ocsigen.org * + * Copyright (C) 2015 Vincent Balat + * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation, with linking exception; @@ -16,39 +18,581 @@ * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *) -[%%client.start] +open%client Js_of_ocaml +open%client Js_of_ocaml_lwt +open%shared Eliom_content.Html +open%shared Eliom_content.Html.F +open%client Lwt.Syntax + +(* ================================================================ *) +(* Reactive form widgets *) +(* ================================================================ *) + +type%shared 'a react_component = + 'a Eliom_shared.React.S.t + * (?step:React.step -> 'a -> unit) Eliom_shared.Value.t + +let%shared cons_opt opt l = match opt with Some x -> x :: l | None -> l + +(* -- Client utilities -------------------------------------------- *) + +let%client resize_textarea (elt : Dom_html.textAreaElement Js.t) = + elt##.style##.overflow := Js.string "hidden"; + elt##.style##.height := Js.string "auto"; + elt##.style##.height := Js.string (string_of_int elt##.scrollHeight ^ "px") + +let%client set_validity e b = + let class_ = Js.string "ot-invalid" in + if b then e##.classList##remove class_ else e##.classList##add class_ + +let%client valid e = + try + (Js.Unsafe.coerce e)##checkValidity + && not (Js.to_bool (e##.classList##contains (Js.string "ot-invalid"))) + with _ -> true + +let%client set_custom_validity inp r = + ignore + @@ + match r with + | Ok _ -> (Js.Unsafe.coerce inp)##setCustomValidity (Js.string "") + | Error s -> (Js.Unsafe.coerce inp)##setCustomValidity (Js.string s) + +let%client select_input_value ev = + Js.Opt.iter ev##.currentTarget @@ fun input -> + match Dom_html.tagged input with + | Dom_html.Input input -> input##select + | _ -> () + +let%client on_enter ~f inp = + Lwt.async @@ fun () -> + Lwt_js_events.keydowns inp @@ fun ev _ -> + if ev##.keyCode = 13 && valid inp + then f (Js.to_string inp##.value) + else Lwt.return_unit + +(* -- Disableable button ------------------------------------------ *) + +let%shared disableable_button ?(a = []) ?button_type ~disabled content = + let button_type = + match button_type with + | Some b -> (b :> Eliom_form_sigs.button_type) + | None -> `Button + in + let a = (a :> Html_types.button_attrib attrib list) in + Form.button_no_value + ~a:(R.filter_attrib (a_disabled ()) disabled :: a) + ~button_type + (content :> Html_types.button_content elt list) + +(* -- Radio buttons ----------------------------------------------- *) + +let%shared + radio + ?(a = []) + ?(disabled_s = Eliom_shared.React.S.const false) + ?(checked_s = Eliom_shared.React.S.const false) + ?name + ?(before = []) + content + = + let a_checked = + R.filter_attrib (a_checked ()) + @@ Eliom_shared.React.S.l2 + [%shared fun checked disabled -> if disabled then false else checked] + checked_s disabled_s + in + let a_disabled = R.filter_attrib (a_disabled ()) disabled_s in + let a' = [a_disabled; a_checked] in + let a' = a_class ["ot-radio-input"] :: a_input_type `Radio :: a' in + let e = + D.input ~a:(match name with Some v -> a_name v :: a' | None -> a') () + in + let l = + D.label + ~a:(a_class ["ot-radio"] :: (a :> Html_types.label_attrib attrib list)) + (before @ [span ~a:[a_class ["ot-radio-label"]] content; e]) + in + l, e + +let%shared + radio_buttons + ?(a = []) + ?(disabled_s = + (Eliom_shared.React.S.const [] : int list Eliom_shared.React.S.t)) + ~(selection_react : int option react_component) + ~name + contents + = + let a = (a :> Html_types.label_attrib attrib list) in + let labels = ref [] in + let s, set = selection_react in + let initial = Eliom_shared.React.S.value s in + let mk_radio i content = + let checked = Option.map (( = ) i) (Eliom_shared.Value.local initial) in + let cl = + R.a_class + @@ Eliom_shared.React.S.map + [%shared + fun disabled -> + if List.mem ~%i disabled + then ["disabled"] + else if ~%checked = Some true + then ["checked"] + else []] + disabled_s + in + let a = cl :: a in + let disabled_s = + Eliom_shared.React.S.map + [%shared fun disabled -> List.mem ~%i disabled] + disabled_s + in + let label, input = + radio ~a ~disabled_s + ?checked_s:(Option.map Eliom_shared.React.S.const checked) + ~name content + in + ignore + [%client + (Lwt.async @@ fun () -> + let input = To_dom.of_input ~%input in + Lwt_js_events.changes input @@ fun _ _ -> + if Js.to_bool input##.checked + then ( + (match Eliom_shared.React.S.value ~%s with + | None -> () + | Some p -> Manip.Class.remove (List.nth !(~%labels) p) "checked"); + Manip.Class.add ~%label "checked"; + ~%set (Some ~%i)); + Lwt.return_unit + : unit)]; + label + in + labels := List.mapi mk_radio contents; + !labels + +let%shared radio_selector ?(a = []) ~selection_react ~name ~label choices = + let buttons = radio_buttons ~name ~selection_react choices in + F.div + ~a:(F.a_class ["ot-radio-selector-container"] :: a) + [ F.div ~a:[F.a_class ["ot-radio-selector-label"]] label + ; F.div ~a:[F.a_class ["ot-radio-selector"]] buttons ] + +(* -- Reactive inputs --------------------------------------------- *) + +let%shared + reactify_input + ?(input_r : string react_component option) + ?(value = "") + ?(validate : (string -> bool) Eliom_client_value.t option) + (e : [`Input | `Textarea] elt) + = + let signal, set_signal = + match input_r with Some r -> r | None -> Eliom_shared.React.S.create value + in + let e = + [%client + (To_dom.of_element ~%e : Js_of_ocaml.Dom_html.element Js_of_ocaml__.Js.t)] + in + let e_with_value = + [%client + ((match Dom_html.tagged ~%e with + | Dom_html.Input e -> (e :> < value : Js.js_string Js.t Js.prop > Js.t) + | Dom_html.Textarea e -> + (e :> < value : Js.js_string Js.t Js.prop > Js.t) + | _ -> assert false) + : < value : + Js_of_ocaml__.Js.js_string Js_of_ocaml__.Js.t Js_of_ocaml__.Js.prop > + Js_of_ocaml__.Js.t)] + in + let set_signal = + [%client + (Eliom_lib.Dom_reference.retain ~%e + ~keep: + (React.S.map + (fun s -> + if Js.to_string ~%e_with_value##.value <> s + then ~%e_with_value##.value := Js.string s) + ~%signal); + (match ~%validate with + | Some f -> + Eliom_lib.Dom_reference.retain ~%e + ~keep:(React.S.map (fun x -> set_validity ~%e (f x)) ~%signal) + | None -> ()); + + let f _ _ = + let v = Js.to_string ~%e_with_value##.value in + ~%set_signal v; Lwt.return_unit + in + Lwt.async (fun () -> Lwt_js_events.inputs ~%e f); + fun value -> ~%set_signal value + : _ -> _)] + in + signal, set_signal + +let%shared + reactive_input + ?(a = []) + ?(input_r : string react_component option) + ?value + ?validate + () + = + let a = (a :> Html_types.input_attrib attrib list) in + let e = + D.Raw.input ~a:(match value with Some v -> a_value v :: a | None -> a) () + in + let signal = reactify_input ?input_r ?value ?validate e in + e, signal + +let%shared + textarea + ?(a = []) + ?a_rows:(rows = 4) + ?(resize = false) + ?a_placeholder:(placeholder = "") + value + = + let elt_ref = ref @@ D.Raw.textarea @@ txt value in + let resize_cb = + [%client + fun e -> + Js.Opt.iter e##.currentTarget @@ fun target -> + match Dom_html.tagged target with + | Dom_html.Textarea elt -> resize_textarea elt + | _ -> ()] + in + let resize_onload = + [%client + fun _ -> + Lwt.async @@ fun () -> + let* () = Lwt_js_events.request_animation_frame () in + resize_textarea @@ To_dom.of_textarea !(~%elt_ref); + Lwt.return_unit] + in + let resize_onload = if resize then Some (a_onload resize_onload) else None in + let resize_oninput = if resize then Some (a_oninput resize_cb) else None in + let a = (a :> Html_types.textarea_attrib Eliom_content.Html.attrib list) in + let ta = + D.Raw.textarea + ~a: + (a_class ["ot-form-input"; "ot-form-textarea"] + :: a_rows rows :: a_placeholder placeholder + :: cons_opt resize_oninput (cons_opt resize_onload a)) + (txt value) + in + elt_ref := ta; + ta + +let%shared + reactive_textarea + ?(a = []) + ?a_rows + ?resize + ?a_placeholder + ?value + ?validate + () + = + let a = (a :> Html_types.textarea_attrib attrib list) in + let e = + textarea ~a ?a_rows ?resize ?a_placeholder + (match value with Some v -> v | None -> "") + in + let signal = reactify_input ?value ?validate e in + e, signal + +(* -- Enter key binding ------------------------------------------- *) + +let%shared + lwt_bind_input_enter + ?(validate : (string -> bool) Eliom_client_value.t option) + ?button + (e : Html_types.input elt) + (f : (string -> unit Lwt.t) Eliom_client_value.t) + = + ignore + [%client + (let e = To_dom.of_input ~%e in + let f = + let f = ~%(f : (string -> unit Lwt.t) Eliom_client_value.t) in + match ~%validate with + | Some validate -> + fun v -> + set_validity e (validate v); + f v + | None -> f + in + on_enter ~f e; + match + ~%(button : [< Html_types.button | Html_types.input] elt option) + with + | Some button -> + Lwt.async @@ fun () -> + Lwt_js_events.clicks (To_dom.of_element button) @@ fun _ _ -> + if valid e then f (Js.to_string e##.value) else Lwt.return_unit + | None -> () + : unit)] + +let%shared lwt_bound_input_enter ?(a = []) ?button ?validate f = + let e = D.Raw.input ~a () in + lwt_bind_input_enter ?button ?validate e f; + e -open Js_of_ocaml +(* -- Checkboxes -------------------------------------------------- *) -[%%client open Js_of_ocaml_lwt] +type%shared checkbox_position = [`Left | `Right | `None] +type%shared checkbox_style = [`Box | `Bullet | `Small_bullet | `Toggle] + +let%shared + checkbox + ?(a = []) + ?(a_inp = []) + ?(required = false) + ?(position = (`Right :> checkbox_position)) + ?(checked = false) + ?(readonly = false) + ?(disabled = false) + ?(style = `Box) + ?(mobile_style : checkbox_style option) + content + = + let mobile_style = + match mobile_style with + | None -> if style = `Box then `Toggle else style + | Some s -> s + in + let inp = + D.Raw.input + ~a: + ((if readonly || disabled then [a_disabled ()] else []) + @ (if required then [a_required ()] else []) + @ a_class ["ot-checkbox-input"] + :: a_input_type `Checkbox + :: (if checked then [a_checked ()] else []) + @ (a_inp :> Html_types.input_attrib attrib list)) + () + in + let box = + let disabled_class = if disabled then Some "disabled" else None in + let style_classes_desktop = + match style with + | `Small_bullet -> + ["ot-checkbox-bullet-desktop"; "ot-small-checkbox-bullet"] + | `Bullet -> ["ot-checkbox-bullet-desktop"] + | `Box -> ["ot-checkbox-box-desktop"] + | `Toggle -> ["ot-checkbox-toggle-desktop"] + in + let style_classes_mobile = + match mobile_style with + | `Small_bullet -> + ["ot-checkbox-bullet-mobile"; "ot-small-checkbox-bullet"] + | `Bullet -> ["ot-checkbox-bullet-mobile"] + | `Box -> ["ot-checkbox-box-mobile"] + | `Toggle -> ["ot-checkbox-toggle-mobile"] + in + let style_classes = style_classes_desktop @ style_classes_mobile in + let position_class = + match position with + | `Left -> Some "ot-checkbox-left" + | `Right -> Some "ot-checkbox-right" + | `None -> None + in + let checkbox_classes = + cons_opt disabled_class ("ot-checkbox" :: style_classes) + in + let label_classes = + match position_class with + | Some c -> ["ot-checkbox-label"; c] + | None -> ["ot-checkbox-label"] + in + label + ~a:(a_class checkbox_classes :: (a :> Html_types.label_attrib attrib list)) + [ inp + ; span + ~a:[a_class label_classes] + [ (span + ~a:[a_class ["ot-checkbox-decoration"]] + [span ~a:[a_class ["ot-checkbox-sub-decoration"]] []] + :> [< Html_types.span_content] Eliom_content.Html.elt) + ; span content ] ] + in + box, inp + +let%shared + reactive_checkbox + ?a + ?a_inp + ?position + ?(checked = false) + ?readonly + ?disabled + ?style + ?mobile_style + ?(ctrl = Eliom_shared.React.S.create checked) + content + = + let box, inp = + checkbox ?a ?a_inp ~checked ?readonly ?position ?disabled ?style + ?mobile_style content + in + let signal, set_signal = ctrl in + let manually_changed, set_manually_changed = + Eliom_shared.React.S.create false + in + let (_ : unit Eliom_client_value.t) = + [%client + let disabled = Option.value ~default:false ~%disabled in + if not disabled + then + let inp' = To_dom.of_input ~%inp in + Eliom_lib.Dom_reference.retain inp' + ~keep: + (Eliom_shared.React.S.map + (fun b -> inp'##.checked := Js.bool b) + ~%signal); + Lwt.async (fun () -> + Lwt_js_events.changes inp' (fun _ _ -> + ~%set_manually_changed true; + ~%set_signal (Js.to_bool inp'##.checked); + Lwt.return_unit))] + in + object + method label = box + method input = inp + method value = signal + method manually_changed = manually_changed + end -open Eliom_content.Html -open Eliom_content.Html.F +(* -- Validation -------------------------------------------------- *) -class type tabbable = object - inherit Dom_html.element - method tabIndex : int Js.prop +let%shared + input_validation_tools + ?init + ?set_focus + ?(result_iter : + (Js_of_ocaml.Dom_html.inputElement Js_of_ocaml.Js.t -> string -> unit) + Eliom_client_value.t + option) + ?(invalid_class = "ot-invalid") + (check : (string -> (string, string) Result.t) Eliom_shared.Value.t) + = + let invalid_cl valid_s lazy_invalid_s = + R.filter_attrib (a_class [invalid_class]) + @@ Eliom_shared.React.S.l2 + [%shared fun valid lazy_invalid -> (not valid) && lazy_invalid] + valid_s lazy_invalid_s + in + let result_s, set_result = + Eliom_shared.React.S.create + @@ + match init with + | Some i -> (Eliom_shared.Value.local check) i + | None -> Ok "" + in + let init_valid = + match init with + | Some i -> Result.is_ok @@ (Eliom_shared.Value.local check) i + | None -> true + in + let valid_s, set_valid = Eliom_shared.React.S.create init_valid in + let init_lazy_invalid = + match init with + | Some i -> Result.is_error @@ (Eliom_shared.Value.local check) i + | None -> false + in + let lazy_invalid_s, set_lazy_invalid = + Eliom_shared.React.S.create init_lazy_invalid + in + let a_oninput_attr = + a_oninput + [%client + fun ev -> + Js.Opt.iter ev##.currentTarget @@ fun target -> + match Dom_html.tagged target with + | Dom_html.Input t -> + let r = ~%check (Js.to_string t##.value) in + ~%set_result r; + ~%set_valid (Result.is_ok r) + | _ -> ()] + in + let a_onfocus_o = + Option.map + (fun set_focus -> a_onfocus [%client fun _ -> ~%set_focus true]) + set_focus + in + let a_onblur_attr = + a_onblur + [%client + fun ev -> + Js.Opt.iter ev##.currentTarget @@ fun target -> + match Dom_html.tagged target with + | Dom_html.Input t -> + let r = ~%check (Js.to_string t##.value) in + Option.iter (fun f -> Result.iter (f t) r) ~%result_iter; + Option.iter (fun f -> f false) ~%set_focus; + ~%set_result r; + set_custom_validity t r; + ~%set_lazy_invalid (Result.is_error r) + | _ -> ()] + in + ( cons_opt a_onfocus_o [a_onblur_attr; a_oninput_attr] + , invalid_cl valid_s lazy_invalid_s + , result_s ) + +let%shared + graceful_invalid_style (inp : Html_types.input Eliom_content.Html.elt) + = + ignore + @@ [%client + (let inp = Eliom_content.Html.To_dom.of_input ~%inp in + let f () = set_validity inp (Js.Unsafe.coerce inp)##checkValidity in + Lwt.async @@ fun () -> + let* _ = Lwt_js_events.blur inp in + f (); + Lwt_js_events.inputs inp @@ fun _ _ -> f (); Lwt.return_unit + : unit)] + +let%shared none_input_value = "-" + +let%shared validate_as_int value = + let value = String.trim value in + if String.length value = 0 || value = none_input_value + then Ok None + else + match int_of_string_opt value with + | None -> Error () + | Some value -> Ok (Some value) + +(* ================================================================ *) +(* Tab cycling (client-only) *) +(* ================================================================ *) + +module%client Tabbable = struct + class type t = object + inherit Dom_html.element + method tabIndex : int Js.prop + end end -let only_if_active' elt v = if Ot_style.invisible elt then None else Some v +let%client only_if_active' elt v = + if Ot_style.invisible elt then None else Some v -let only_if_active elt v = +let%client only_if_active elt v = if elt##.disabled = Js._true || Ot_style.invisible elt then None else Some v -let coerce_to_tabbable x = +let%client coerce_to_tabbable x = let x = Dom_html.element x in match Dom_html.tagged x with - | Dom_html.A x -> only_if_active' x (x :> tabbable Js.t) - (* | Dom_html.Link x -> Some (x :> tabbable Js.t) *) - | Dom_html.Button x -> only_if_active x (x :> tabbable Js.t) - | Dom_html.Input x -> only_if_active x (x :> tabbable Js.t) - | Dom_html.Select x -> only_if_active x (x :> tabbable Js.t) - | Dom_html.Textarea x -> only_if_active x (x :> tabbable Js.t) - (* | Dom_html.Menuitem x -> Some (x :> tabbable Js.t) *) + | Dom_html.A x -> only_if_active' x (x :> Tabbable.t Js.t) + | Dom_html.Button x -> only_if_active x (x :> Tabbable.t Js.t) + | Dom_html.Input x -> only_if_active x (x :> Tabbable.t Js.t) + | Dom_html.Select x -> only_if_active x (x :> Tabbable.t Js.t) + | Dom_html.Textarea x -> only_if_active x (x :> Tabbable.t Js.t) | _ -> None -(* https://www.w3.org/TR/html5/editing.html#sequential-focus-navigation-and-the-tabindex-attribute *) -let tabbable_elts_of elt = +let%client tabbable_elts_of elt = elt##querySelectorAll (Js.string "a[href],link[href],button,input:not([type=\"hidden\"]),select,textarea,[ot-form-focusable]") @@ -57,7 +601,7 @@ let tabbable_elts_of elt = |> List.fold_left (fun a -> function Some x -> x :: a | _ -> a) [] |> List.rev -let setup_tabcycle (elts : #tabbable Js.t list) : unit = +let%client setup_tabcycle (elts : #Tabbable.t Js.t list) : unit = let rec fn n = function | [x] -> x##.tabIndex := n; @@ -78,10 +622,13 @@ let setup_tabcycle (elts : #tabbable Js.t list) : unit = in fn 2 elts -let setup_tabcycle_auto x = setup_tabcycle (tabbable_elts_of x) -let focus_first = function x :: _ -> (Js.Unsafe.coerce x)##focus | [] -> () +let%client setup_tabcycle_auto x = setup_tabcycle (tabbable_elts_of x) + +let%client focus_first = function + | x :: _ -> (Js.Unsafe.coerce x)##focus + | [] -> () -let prevent_tab elt = +let%client prevent_tab elt = let save_and_set_tabindex idx elt = let old = elt##.tabIndex in elt##.tabIndex := idx; @@ -91,6 +638,7 @@ let prevent_tab elt = let elts = List.map (save_and_set_tabindex (-1)) (tabbable_elts_of elt) in fun () -> List.iter restore_tabindex elts -let setup_form element = +let%client setup_form element = let elts = tabbable_elts_of element in setup_tabcycle elts; focus_first elts + diff --git a/src/widgets/ot_form.eliomi b/src/widgets/ot_form.eliomi index debaf68b..22c9a62f 100644 --- a/src/widgets/ot_form.eliomi +++ b/src/widgets/ot_form.eliomi @@ -1,6 +1,8 @@ (* Ocsigen * http://www.ocsigen.org * + * Copyright (C) 2015 Vincent Balat + * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation, with linking exception; @@ -16,30 +18,242 @@ * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *) -[%%client.start] +open%client Js_of_ocaml +open%shared Eliom_content.Html + +(** {2 Reactive form widgets} *) + +type%shared 'a react_component = + 'a Eliom_shared.React.S.t + * (?step:React.step -> 'a -> unit) Eliom_shared.Value.t +(** A reactive component: a signal and its setter. *) + +(** {3 Client-side utilities} *) + +val%client resize_textarea : Dom_html.textAreaElement Js.t -> unit +(** Auto-resize a textarea element to fit its content. *) + +val%client set_validity : Dom_html.element Js.t -> bool -> unit +(** [set_validity e b] adds or removes the ["ot-invalid"] class on [e]. *) + +val%client valid : Dom_html.element Js.t -> bool +(** [valid e] checks HTML5 validity and absence of the ["ot-invalid"] class. *) + +val%client set_custom_validity : + Dom_html.element Js.t + -> (string, string) result + -> unit +(** [set_custom_validity e r] sets the HTML5 custom validity message on [e]. *) + +val%client select_input_value : Dom_html.event Js.t -> unit +(** [select_input_value ev] selects the text content of the input + that triggered [ev]. *) + +val%client on_enter : + f:(string -> unit Lwt.t) + -> Dom_html.inputElement Js.t + -> unit +(** [on_enter ~f inp] calls [f] with the input value when the user + presses Enter, provided the input is valid. *) + +(** {3 Buttons} *) + +val%shared disableable_button : + ?a:[< Html_types.button_attrib] attrib list + -> ?button_type:[< Eliom_form_sigs.button_type] + -> disabled:bool Eliom_shared.React.S.t + -> [< Html_types.button_content] elt list + -> [> `Button] elt +(** A button that can be reactively disabled. *) + +(** {3 Radio buttons} *) + +val%shared radio : + ?a:[< Html_types.label_attrib] attrib list + -> ?disabled_s:bool Eliom_shared.React.S.t + -> ?checked_s:bool Eliom_shared.React.S.t + -> ?name:string + -> ?before:[< Html_types.label_content_fun > `Input `Span] elt list + -> [< Html_types.span_content] elt list + -> [> `Label] elt * [> `Input] elt +(** A single radio button with label. *) + +val%shared radio_buttons : + ?a:[< Html_types.label_attrib] attrib list + -> ?disabled_s:int list Eliom_shared.React.S.t + -> selection_react:int option react_component + -> name:string + -> [< Html_types.span_content] elt list list + -> Html_types.div_content elt list +(** A group of radio buttons with reactive selection. *) + +val%shared radio_selector : + ?a:[< Html_types.div_attrib > `Class] attrib list + -> selection_react:int option react_component + -> name:string + -> label:[< Html_types.div_content] elt list + -> [< Html_types.span_content] elt list list + -> [> `Div] elt +(** A styled container with label for a group of radio buttons. *) + +(** {3 Reactive inputs} *) + +val%shared reactify_input : + ?input_r:string react_component + -> ?value:string + -> ?validate:(string -> bool) Eliom_client_value.t + -> [`Input | `Textarea] elt + -> string Eliom_shared.React.S.t * (string -> unit) Eliom_client_value.t +(** Make an existing input or textarea element reactive. + Returns a signal tracking the current value and a setter. *) + +val%shared reactive_input : + ?a:[< Html_types.input_attrib] attrib list + -> ?input_r:string react_component + -> ?value:string + -> ?validate:(string -> bool) Eliom_client_value.t + -> unit + -> [> `Input] elt + * (string Eliom_shared.React.S.t * (string -> unit) Eliom_client_value.t) +(** Create a reactive text input. Returns the element and + a (signal, setter) pair. *) + +val%shared textarea : + ?a:[< Html_types.textarea_attrib] attrib list + -> ?a_rows:int + -> ?resize:bool + -> ?a_placeholder:string + -> string + -> [> `Textarea] elt +(** Create a textarea, optionally with auto-resize. *) + +val%shared reactive_textarea : + ?a:[< Html_types.textarea_attrib] attrib list + -> ?a_rows:int + -> ?resize:bool + -> ?a_placeholder:string + -> ?value:string + -> ?validate:(string -> bool) Eliom_client_value.t + -> unit + -> [> `Textarea] elt + * (string Eliom_shared.React.S.t * (string -> unit) Eliom_client_value.t) +(** Create a reactive textarea. *) + +(** {3 Enter key binding} *) + +val%shared lwt_bind_input_enter : + ?validate:(string -> bool) Eliom_client_value.t + -> ?button:[`Button] elt + -> [`Input] elt + -> (string -> unit Lwt.t) Eliom_client_value.t + -> unit +(** Bind an Lwt action to an existing input, triggered on Enter key + or optional button click. *) + +val%shared lwt_bound_input_enter : + ?a:[< Html_types.input_attrib] attrib list + -> ?button:[`Button] elt + -> ?validate:(string -> bool) Eliom_client_value.t + -> (string -> unit Lwt.t) Eliom_client_value.t + -> [> `Input] elt +(** Create an input with an Lwt action triggered on Enter key. *) + +(** {3 Checkboxes} *) + +type%shared checkbox_position = [`Left | `Right | `None] +type%shared checkbox_style = [`Box | `Bullet | `Small_bullet | `Toggle] + +val%shared checkbox : + ?a:[< Html_types.label_attrib] attrib list + -> ?a_inp:[< Html_types.input_attrib] attrib list + -> ?required:bool + -> ?position:checkbox_position + -> ?checked:bool + -> ?readonly:bool + -> ?disabled:bool + -> ?style:checkbox_style + -> ?mobile_style:checkbox_style + -> [< Html_types.span_content] elt list + -> [> `Label] elt * [> `Input] elt +(** A customizable checkbox with position and style options. *) + +val%shared reactive_checkbox : + ?a:[< Html_types.label_attrib] attrib list + -> ?a_inp:[< Html_types.input_attrib] attrib list + -> ?position:checkbox_position + -> ?checked:bool + -> ?readonly:bool + -> ?disabled:bool + -> ?style:checkbox_style + -> ?mobile_style:checkbox_style + -> ?ctrl: + bool Eliom_shared.React.S.t + * (?step:React.step -> bool -> unit) Eliom_shared.Value.t + -> [< Html_types.span_content] elt list + -> < label : [> `Label] elt + ; input : [> `Input] elt + ; value : bool Eliom_shared.React.S.t + ; manually_changed : bool Eliom_shared.React.S.t > +(** A reactive checkbox. The returned object provides: + - [label]: the label element + - [input]: the input element + - [value]: a signal tracking whether it is checked + - [manually_changed]: a signal tracking whether the user changed it *) + +(** {3 Validation} *) + +val%shared input_validation_tools : + ?init:string + -> ?set_focus:(bool -> unit) Eliom_client_value.t + -> ?result_iter: + (Js_of_ocaml.Dom_html.inputElement Js_of_ocaml.Js.t -> string -> unit) + Eliom_client_value.t + -> ?invalid_class:string + -> (string -> (string, string) Result.t) Eliom_shared.Value.t + -> [> Html_types.input_attrib] attrib list + * [> `Class] attrib + * (string, string) Result.t Eliom_shared.React.S.t +(** Given a validation function, builds the necessary [oninput] and [onblur] + attributes for an input, a reactive class attribute for error states, + and a signal with the validation result. + + The invalid class is only shown after the first blur (graceful). *) + +val%shared graceful_invalid_style : [`Input] elt -> unit +(** Adds the ["ot-invalid"] class after each blur. This allows styling + for invalidity without showing errors before the user interacts. + Inspired by [:-moz-ui-invalid]. *) + +val%shared validate_as_int : string -> (int option, unit) result +(** Validate a string as an optional integer. Returns [Ok None] for + empty strings or ["-"], [Ok (Some n)] for valid integers, + [Error ()] otherwise. *) + +val%shared none_input_value : string +(** The string ["-"], used as placeholder for empty optional int inputs. *) -open Js_of_ocaml -open Eliom_content.Html -open Html_types +(** {2 Tab cycling (client-only)} *) -(** An HTML element which can be selected by pressing the tab key *) -class type tabbable = object - inherit Dom_html.element - method tabIndex : int Js.prop +module%client Tabbable : sig + (** An HTML element which can be selected by pressing the tab key. *) + class type t = object + inherit Dom_html.element + method tabIndex : int Js.prop + end end -val setup_tabcycle : #tabbable Js.t list -> unit +val%client setup_tabcycle : #Tabbable.t Js.t list -> unit (** [setup_tabcycle] makes tab key loop over child elements of an element and only these elements. *) -val setup_tabcycle_auto : Dom_html.element Js.t -> unit +val%client setup_tabcycle_auto : Dom_html.element Js.t -> unit (** [setup_tabcycle_auto] scans an element for tabbable elements (buttons, inputs) and feeds them to [setup_tabcycle] *) -val setup_form : Dom_html.element Js.t -> unit -(** Scan for focusable elements apply [setup_tabcycle_auto] to them and +val%client setup_form : Dom_html.element Js.t -> unit +(** Scan for focusable elements, apply [setup_tabcycle_auto] to them and focus the first. *) -val prevent_tab : Dom_html.element Js.t -> unit -> unit +val%client prevent_tab : Dom_html.element Js.t -> unit -> unit (** [prevent_tab e] prevents [e] (and its children) to be focused with tab key. A function to restore the initial status is returned. *) From cac7594f6f3317a091ef022117f552317a67e73f Mon Sep 17 00:00:00 2001 From: Vincent Balat Date: Fri, 27 Feb 2026 11:20:12 +0100 Subject: [PATCH 03/10] Ot_form: add reactive_select widget A ] element with + reactive selection tracking. [options] is a list of [(value, label)] + pairs. Returns the element and a [(signal, setter)] pair. + The setter can be used to change the selection programmatically. *) + +(** {3 Misc} *) + val%shared validate_as_int : string -> (int option, unit) result (** Validate a string as an optional integer. Returns [Ok None] for empty strings or ["-"], [Ok (Some n)] for valid integers, From 00e0e691fc747716ec1beb24b9038af10c163770 Mon Sep 17 00:00:00 2001 From: Vincent Balat Date: Fri, 27 Feb 2026 11:20:49 +0100 Subject: [PATCH 04/10] Ot_form: add reactive_toggle_button widget A simple on/off toggle button with reactive CSS classes (ot-toggle-on / ot-toggle-off). No dependency on Bs_buttons. Co-Authored-By: Claude Opus 4.6 --- css/ot_form.css | 37 +++++++++++++++++++++++++++++++++++++ src/widgets/ot_form.eliom | 32 ++++++++++++++++++++++++++++++++ src/widgets/ot_form.eliomi | 12 ++++++++++++ 3 files changed, 81 insertions(+) diff --git a/css/ot_form.css b/css/ot_form.css index 5c2b59e6..87f1a645 100644 --- a/css/ot_form.css +++ b/css/ot_form.css @@ -1,3 +1,40 @@ +/* ================================================================ */ +/* Toggle button */ +/* ================================================================ */ + +.ot-toggle-button { + display: inline-flex; + align-items: center; + padding: 0.4em 1em; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.15s, border-color 0.15s, color 0.15s; +} + +.ot-toggle-off { + background-color: #4a90d9; + border-color: #4a90d9; + color: white; +} + +.ot-toggle-off:hover { + background-color: #3a7bc8; + border-color: #3a7bc8; +} + +.ot-toggle-on { + background-color: #3570a8; + border-color: #2d6090; + color: white; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.ot-toggle-on:hover { + background-color: #2d6090; + border-color: #265580; +} + /* ================================================================ */ /* Checkbox */ /* ================================================================ */ diff --git a/src/widgets/ot_form.eliom b/src/widgets/ot_form.eliom index ba6a7722..1031d40b 100644 --- a/src/widgets/ot_form.eliom +++ b/src/widgets/ot_form.eliom @@ -85,6 +85,38 @@ let%shared disableable_button ?(a = []) ?button_type ~disabled content = ~button_type (content :> Html_types.button_content elt list) +(* -- Toggle button ----------------------------------------------- *) + +let%shared + reactive_toggle_button + ?(a = []) + ?(init = false) + ?(ctrl = Eliom_shared.React.S.create init) + content + = + let signal, set_signal = ctrl in + let elt = + D.button + ~a: + (a_onclick + [%client + fun ev -> + Dom_html.stopPropagation ev; + Dom.preventDefault ev; + ~%set_signal (not (React.S.value ~%signal))] + :: a_button_type `Button + :: R.a_class + (Eliom_shared.React.S.map + [%shared + fun checked -> + "ot-toggle-button" + :: (if checked then ["ot-toggle-on"] else ["ot-toggle-off"])] + signal) + :: (a :> Html_types.button_attrib attrib list)) + content + in + elt, (signal, set_signal) + (* -- Radio buttons ----------------------------------------------- *) let%shared diff --git a/src/widgets/ot_form.eliomi b/src/widgets/ot_form.eliomi index 2b6a30e7..c43b31c5 100644 --- a/src/widgets/ot_form.eliomi +++ b/src/widgets/ot_form.eliomi @@ -66,6 +66,18 @@ val%shared disableable_button : -> [> `Button] elt (** A button that can be reactively disabled. *) +(** {3 Toggle button} *) + +val%shared reactive_toggle_button : + ?a:[< Html_types.button_attrib] attrib list + -> ?init:bool + -> ?ctrl:bool react_component + -> [< Html_types.button_content] elt list + -> [> `Button] elt * bool react_component +(** A toggle button that alternates between on/off states. + The CSS classes ["ot-toggle-on"] and ["ot-toggle-off"] are set + reactively on the button element. *) + (** {3 Radio buttons} *) val%shared radio : From 2b0663487e6bb1e68a8f54b620d43e83464dd89a Mon Sep 17 00:00:00 2001 From: Vincent Balat Date: Fri, 27 Feb 2026 11:21:59 +0100 Subject: [PATCH 05/10] Ot_form: add int_input and optional_int_input widgets Integer inputs with +/- step buttons using simple HTML buttons (no Bs_icons dependency). Includes validate_as_int helper. Co-Authored-By: Claude Opus 4.6 --- src/widgets/ot_form.eliom | 99 +++++++++++++++++++++++++++++++++++++- src/widgets/ot_form.eliomi | 19 ++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/src/widgets/ot_form.eliom b/src/widgets/ot_form.eliom index 1031d40b..09b84744 100644 --- a/src/widgets/ot_form.eliom +++ b/src/widgets/ot_form.eliom @@ -623,7 +623,7 @@ let%shared reactive_select ?(a = []) ~options ?selected () = in elt, (signal, set_signal) -(* -- Misc ------------------------------------------------------- *) +(* -- Integer inputs ---------------------------------------------- *) let%shared none_input_value = "-" @@ -636,6 +636,103 @@ let%shared validate_as_int value = | None -> Error () | Some value -> Ok (Some value) +let%shared + int_step_button + ~min_value + ~max_value + ~value + ~set_value + ~input_elt + ~optional + step + = + let disabled = + Eliom_shared.React.S.map + [%shared + function + | Error () -> true + | Ok None -> ~%step < 0 + | Ok (Some v) -> + if ~%step < 0 + then if ~%optional then v < ~%min_value else v <= ~%min_value + else v >= ~%max_value] + value + in + let execute_step = + [%client + fun _ -> + let value = + match Eliom_shared.React.S.value ~%value with + | Ok None | Error () -> pred ~%min_value + | Ok (Some value) -> value + in + let s = + if ~%step < 0 && value = ~%min_value && ~%optional + then none_input_value + else string_of_int @@ ( + ) ~%step @@ value + in + ~%set_value s; + (To_dom.of_input ~%input_elt)##.value := Js.string s] + in + disableable_button + ~a:[F.a_onclick execute_step; F.a_class ["ot-form-step-button"]] + ~disabled + [F.txt (if step < 0 then "\xe2\x88\x92" else "+")] + +let%shared make_int_input ~min ~max ~size ~optional initial_value = + let input_r = + let initial_value = + match initial_value with + | None -> none_input_value + | Some value -> string_of_int value + in + Eliom_shared.React.S.create initial_value + in + let input, value = + let input, (value, _) = + reactive_input + ~validate:[%client fun x -> Result.is_ok (validate_as_int x)] + ~input_r + ~a: + [ a_input_type `Text + ; a_inputmode `Numeric + ; a_size size + ; a_onfocus [%client select_input_value] + ; a_class ["ot-form-input"] ] + () + in + input, Eliom_shared.React.S.map [%shared validate_as_int] value + in + let less_button, more_button = + let step_button = + int_step_button ~min_value:min ~max_value:max ~value + ~set_value:(snd input_r) ~input_elt:input ~optional + in + step_button (-1), step_button 1 + in + ( F.div ~a:[F.a_class ["ot-form-int-input"]] [less_button; input; more_button] + , value ) + +let%shared + optional_int_input ?(min = 0) ?(max = max_int) ?(size = 2) initial_value + = + make_int_input ~min ~max ~size ~optional:true initial_value + +let%shared int_input ?(min = 0) ?(max = max_int) ?(size = 2) initial_value = + let buttons, value = + make_int_input ~min ~max ~size ~optional:false (Some initial_value) + in + let value = + Eliom_shared.React.S.map + [%shared + fun result -> + match result with + | Ok (Some s) -> Ok s + | Ok None | Error () -> Error ()] + value + in + buttons, value + (* ================================================================ *) (* Tab cycling (client-only) *) (* ================================================================ *) diff --git a/src/widgets/ot_form.eliomi b/src/widgets/ot_form.eliomi index c43b31c5..8d5a5f51 100644 --- a/src/widgets/ot_form.eliomi +++ b/src/widgets/ot_form.eliomi @@ -236,6 +236,25 @@ val%shared graceful_invalid_style : [`Input] elt -> unit for invalidity without showing errors before the user interacts. Inspired by [:-moz-ui-invalid]. *) +(** {3 Integer inputs} *) + +val%shared optional_int_input : + ?min:int + -> ?max:int + -> ?size:int + -> int option + -> [> `Div] elt * (int option, unit) result Eliom_shared.React.S.t +(** An integer input with +/- buttons that can be empty (None). + Displays ["-"] when empty. *) + +val%shared int_input : + ?min:int + -> ?max:int + -> ?size:int + -> int + -> [> `Div] elt * (int, unit) result Eliom_shared.React.S.t +(** An integer input with +/- buttons. Always contains a value. *) + (** {3 Reactive select} *) val%shared reactive_select : From 00ae8c5c9ff4daee6fd8ba6c7973635d492e69ef Mon Sep 17 00:00:00 2001 From: Vincent Balat Date: Fri, 27 Feb 2026 11:25:20 +0100 Subject: [PATCH 06/10] Ot_form: add prevent_double_submit utility Co-Authored-By: Claude Opus 4.6 --- src/widgets/ot_form.eliom | 18 ++++++++++++++++++ src/widgets/ot_form.eliomi | 11 +++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/widgets/ot_form.eliom b/src/widgets/ot_form.eliom index 09b84744..7735e162 100644 --- a/src/widgets/ot_form.eliom +++ b/src/widgets/ot_form.eliom @@ -623,6 +623,24 @@ let%shared reactive_select ?(a = []) ~options ?selected () = in elt, (signal, set_signal) +(* -- Prevent double submit --------------------------------------- *) + +let%shared prevent_double_submit ?(a = []) ?button_type ~f content = + let disabled_s, set_disabled = Eliom_shared.React.S.create false in + let onclick = + [%client + fun _ -> + if not (React.S.value ~%disabled_s) + then begin + ~%set_disabled true; + Lwt.async (fun () -> + Lwt.finalize ~%f (fun () -> ~%set_disabled false; Lwt.return_unit)) + end] + in + disableable_button + ~a:(a_onclick onclick :: (a :> Html_types.button_attrib attrib list)) + ?button_type ~disabled:disabled_s content + (* -- Integer inputs ---------------------------------------------- *) let%shared none_input_value = "-" diff --git a/src/widgets/ot_form.eliomi b/src/widgets/ot_form.eliomi index 8d5a5f51..b6b900b0 100644 --- a/src/widgets/ot_form.eliomi +++ b/src/widgets/ot_form.eliomi @@ -255,6 +255,17 @@ val%shared int_input : -> [> `Div] elt * (int, unit) result Eliom_shared.React.S.t (** An integer input with +/- buttons. Always contains a value. *) +(** {3 Prevent double submit} *) + +val%shared prevent_double_submit : + ?a:[< Html_types.button_attrib] attrib list + -> ?button_type:[< Eliom_form_sigs.button_type] + -> f:(unit -> unit Lwt.t) Eliom_client_value.t + -> [< Html_types.button_content] elt list + -> [> `Button] elt +(** A button that disables itself while the action [f] is running, + preventing double submissions. *) + (** {3 Reactive select} *) val%shared reactive_select : From 738fdf7f807d4c3e0f1b4b475f09ac2c67d1cf1e Mon Sep 17 00:00:00 2001 From: Vincent Balat Date: Fri, 27 Feb 2026 11:28:56 +0100 Subject: [PATCH 07/10] Ot_form: add password_input with visibility toggle Co-Authored-By: Claude Opus 4.6 --- css/ot_form.css | 58 ++++++++++++++++++++++++++++++++++++++ src/widgets/ot_form.eliom | 44 +++++++++++++++++++++++++++++ src/widgets/ot_form.eliomi | 13 +++++++++ 3 files changed, 115 insertions(+) diff --git a/css/ot_form.css b/css/ot_form.css index 87f1a645..48a27bf6 100644 --- a/css/ot_form.css +++ b/css/ot_form.css @@ -35,6 +35,64 @@ border-color: #265580; } +/* ================================================================ */ +/* Password input */ +/* ================================================================ */ + +.ot-password-container { + display: flex; + align-items: center; +} + +.ot-password-container > .ot-password-input { + flex: 1; +} + +.ot-password-toggle { + display: flex; + align-items: center; + justify-content: center; + padding: 0.4em 0.6em; + border: 1px solid #ccc; + border-left: none; + background: #f5f5f5; + cursor: pointer; + line-height: 1; +} + +.ot-password-toggle:hover { + background: #e8e8e8; +} + +/* Pressed look when password is visible (toggle shows the "hide" icon) */ +.ot-password-toggle:has(.ot-password-toggle-hide) { + background: #e0e0e0; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2); +} + +/* The toggle contains a span that gets class ot-password-toggle-show + or ot-password-toggle-hide via R.a_class. We use background-image + SVGs so the icon is visible even though the span has no text. */ +.ot-password-toggle-show, +.ot-password-toggle-hide { + display: inline-block; + width: 1.4em; + height: 1.4em; + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +/* Eye open (Feather icon) */ +.ot-password-toggle-show { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23555' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E"); +} + +/* Eye off / barred (Feather icon) */ +.ot-password-toggle-hide { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23555' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24'/%3E%3Cline x1='1' y1='1' x2='23' y2='23'/%3E%3C/svg%3E"); +} + /* ================================================================ */ /* Checkbox */ /* ================================================================ */ diff --git a/src/widgets/ot_form.eliom b/src/widgets/ot_form.eliom index 7735e162..0b81a781 100644 --- a/src/widgets/ot_form.eliom +++ b/src/widgets/ot_form.eliom @@ -623,6 +623,50 @@ let%shared reactive_select ?(a = []) ~options ?selected () = in elt, (signal, set_signal) +(* -- Password input ---------------------------------------------- *) + +let%shared password_input ?(a = []) ?placeholder () = + let visible_s, set_visible = Eliom_shared.React.S.create false in + let inp = + D.Raw.input + ~a: + (R.a_input_type + (Eliom_shared.React.S.map + [%shared fun visible -> if visible then `Text else `Password] + visible_s) + :: a_class ["ot-form-input"; "ot-password-input"] + :: cons_opt + (Option.map a_placeholder placeholder) + (a :> Html_types.input_attrib attrib list)) + () + in + let toggle = + D.button + ~a: + [ a_button_type `Button + ; a_class ["ot-password-toggle"] + ; a_onclick + [%client + fun ev -> + Dom_html.stopPropagation ev; + Dom.preventDefault ev; + ~%set_visible (not (React.S.value ~%visible_s))] ] + [ D.span + ~a: + [ R.a_class + (Eliom_shared.React.S.map + [%shared + fun visible -> + if visible + then ["ot-password-toggle-hide"] + else ["ot-password-toggle-show"]] + visible_s) ] + [] ] + in + ( F.div ~a:[F.a_class ["ot-password-container"]] [inp; toggle] + , inp + , (visible_s, set_visible) ) + (* -- Prevent double submit --------------------------------------- *) let%shared prevent_double_submit ?(a = []) ?button_type ~f content = diff --git a/src/widgets/ot_form.eliomi b/src/widgets/ot_form.eliomi index b6b900b0..7d256a51 100644 --- a/src/widgets/ot_form.eliomi +++ b/src/widgets/ot_form.eliomi @@ -255,6 +255,19 @@ val%shared int_input : -> [> `Div] elt * (int, unit) result Eliom_shared.React.S.t (** An integer input with +/- buttons. Always contains a value. *) +(** {3 Password input} *) + +val%shared password_input : + ?a:[< Html_types.input_attrib] attrib list + -> ?placeholder:string + -> unit + -> [> `Div] elt * [> `Input] elt * bool react_component +(** A password input with a visibility toggle button. + Returns [(container, input, (visible_signal, set_visible))]. + The toggle switches between [type=password] and [type=text]. + CSS classes: [ot-password-container], [ot-password-input], + [ot-password-toggle], [ot-password-toggle-show], [ot-password-toggle-hide]. *) + (** {3 Prevent double submit} *) val%shared prevent_double_submit : From d3eaa93437cef37bcb2ec583c6fc7e0e14ce5ea9 Mon Sep 17 00:00:00 2001 From: Vincent Balat Date: Fri, 27 Feb 2026 11:36:16 +0100 Subject: [PATCH 08/10] Ot_form: add debounced_input widget Co-Authored-By: Claude Opus 4.6 --- src/widgets/ot_form.eliom | 21 +++++++++++++++++++++ src/widgets/ot_form.eliomi | 18 ++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/widgets/ot_form.eliom b/src/widgets/ot_form.eliom index 0b81a781..5a0ef206 100644 --- a/src/widgets/ot_form.eliom +++ b/src/widgets/ot_form.eliom @@ -623,6 +623,27 @@ let%shared reactive_select ?(a = []) ~options ?selected () = in elt, (signal, set_signal) +(* -- Debounced input --------------------------------------------- *) + +let%shared debounced_input ?(a = []) ?(delay = 0.3) ?value ?validate () = + let input, (raw_signal, set) = reactive_input ~a ?value ?validate () in + let init = match value with Some v -> v | None -> "" in + let debounced, set_debounced = Eliom_shared.React.S.create init in + let (_ : unit Eliom_client_value.t) = + [%client + let pending = ref Lwt.return_unit in + let el = To_dom.of_input ~%input in + Lwt.async (fun () -> + Lwt_js_events.inputs el (fun _ _ -> + Lwt.cancel !pending; + (pending := + let* () = Lwt_js.sleep ~%delay in + ~%set_debounced (Js.to_string el##.value); + Lwt.return_unit); + Lwt.return_unit))] + in + input, (raw_signal, debounced, set) + (* -- Password input ---------------------------------------------- *) let%shared password_input ?(a = []) ?placeholder () = diff --git a/src/widgets/ot_form.eliomi b/src/widgets/ot_form.eliomi index 7d256a51..fc22e9e5 100644 --- a/src/widgets/ot_form.eliomi +++ b/src/widgets/ot_form.eliomi @@ -151,6 +151,24 @@ val%shared reactive_textarea : * (string Eliom_shared.React.S.t * (string -> unit) Eliom_client_value.t) (** Create a reactive textarea. *) +(** {3 Debounced input} *) + +val%shared debounced_input : + ?a:[< Html_types.input_attrib] attrib list + -> ?delay:float + -> ?value:string + -> ?validate:(string -> bool) Eliom_client_value.t + -> unit + -> [> `Input] elt + * (string Eliom_shared.React.S.t + * string Eliom_shared.React.S.t + * (string -> unit) Eliom_client_value.t) +(** A reactive input where the debounced signal updates only after + [delay] seconds (default 0.3) of inactivity. Returns + [(input, (raw_signal, debounced_signal, setter))]. + [raw_signal] updates on every keystroke; [debounced_signal] + waits for the user to stop typing. *) + (** {3 Enter key binding} *) val%shared lwt_bind_input_enter : From 4ede7453a77b874187952757ee7a98e5a1b63b55 Mon Sep 17 00:00:00 2001 From: Vincent Balat Date: Fri, 27 Feb 2026 11:38:13 +0100 Subject: [PATCH 09/10] Ot_form: add reactive_date_input and reactive_time_input Co-Authored-By: Claude Opus 4.6 --- src/widgets/ot_form.eliom | 80 ++++++++++++++++++++++++++++++++++++++ src/widgets/ot_form.eliomi | 30 ++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/src/widgets/ot_form.eliom b/src/widgets/ot_form.eliom index 5a0ef206..51d86415 100644 --- a/src/widgets/ot_form.eliom +++ b/src/widgets/ot_form.eliom @@ -816,6 +816,86 @@ let%shared int_input ?(min = 0) ?(max = max_int) ?(size = 2) initial_value = in buttons, value +(* -- Date and time inputs ---------------------------------------- *) + +let%shared parse_date s = + try Scanf.sscanf s "%4d-%2d-%2d" (fun y m d -> Some (y, m, d)) + with _ -> None + +let%shared parse_time s = + try Scanf.sscanf s "%2d:%2d" (fun h m -> Some (h, m)) with _ -> None + +let%shared string_of_date (y, m, d) = Printf.sprintf "%04d-%02d-%02d" y m d +let%shared string_of_time (h, m) = Printf.sprintf "%02d:%02d" h m + +let%shared reactive_date_input ?(a = []) ?value () = + let initial_str = + match value with Some v -> string_of_date v | None -> "" + in + let signal, set_signal = Eliom_shared.React.S.create value in + let inp = + D.Raw.input + ~a: + (a_input_type `Date + :: a_class ["ot-form-input"; "ot-date-input"] + :: cons_opt + (if initial_str <> "" then Some (a_value initial_str) else None) + (a :> Html_types.input_attrib attrib list)) + () + in + let (_ : unit Eliom_client_value.t) = + [%client + let inp' = To_dom.of_input ~%inp in + Lwt.async (fun () -> + Lwt_js_events.changes inp' @@ fun _ _ -> + let v = Js.to_string inp'##.value in + ~%set_signal (parse_date v); + Lwt.return_unit); + Eliom_lib.Dom_reference.retain inp' + ~keep: + (React.S.map + (fun v -> + let s = match v with Some d -> string_of_date d | None -> "" in + if Js.to_string inp'##.value <> s + then inp'##.value := Js.string s) + ~%signal)] + in + inp, (signal, set_signal) + +let%shared reactive_time_input ?(a = []) ?value () = + let initial_str = + match value with Some v -> string_of_time v | None -> "" + in + let signal, set_signal = Eliom_shared.React.S.create value in + let inp = + D.Raw.input + ~a: + (a_input_type `Time + :: a_class ["ot-form-input"; "ot-time-input"] + :: cons_opt + (if initial_str <> "" then Some (a_value initial_str) else None) + (a :> Html_types.input_attrib attrib list)) + () + in + let (_ : unit Eliom_client_value.t) = + [%client + let inp' = To_dom.of_input ~%inp in + Lwt.async (fun () -> + Lwt_js_events.changes inp' @@ fun _ _ -> + let v = Js.to_string inp'##.value in + ~%set_signal (parse_time v); + Lwt.return_unit); + Eliom_lib.Dom_reference.retain inp' + ~keep: + (React.S.map + (fun v -> + let s = match v with Some t -> string_of_time t | None -> "" in + if Js.to_string inp'##.value <> s + then inp'##.value := Js.string s) + ~%signal)] + in + inp, (signal, set_signal) + (* ================================================================ *) (* Tab cycling (client-only) *) (* ================================================================ *) diff --git a/src/widgets/ot_form.eliomi b/src/widgets/ot_form.eliomi index fc22e9e5..204b9027 100644 --- a/src/widgets/ot_form.eliomi +++ b/src/widgets/ot_form.eliomi @@ -320,6 +320,36 @@ val%shared validate_as_int : string -> (int option, unit) result val%shared none_input_value : string (** The string ["-"], used as placeholder for empty optional int inputs. *) +(** {3 Date and time inputs} *) + +val%shared parse_date : string -> (int * int * int) option +(** Parse a date string in [YYYY-MM-DD] format. *) + +val%shared parse_time : string -> (int * int) option +(** Parse a time string in [HH:MM] format. *) + +val%shared string_of_date : int * int * int -> string +(** Format a [(year, month, day)] triple as [YYYY-MM-DD]. *) + +val%shared string_of_time : int * int -> string +(** Format a [(hours, minutes)] pair as [HH:MM]. *) + +val%shared reactive_date_input : + ?a:[< Html_types.input_attrib] attrib list + -> ?value:int * int * int + -> unit + -> [> `Input] elt * (int * int * int) option react_component +(** A reactive HTML5 date input. The signal carries + [Some (year, month, day)] or [None] when empty. *) + +val%shared reactive_time_input : + ?a:[< Html_types.input_attrib] attrib list + -> ?value:int * int + -> unit + -> [> `Input] elt * (int * int) option react_component +(** A reactive HTML5 time input. The signal carries + [Some (hours, minutes)] or [None] when empty. *) + (** {2 Tab cycling (client-only)} *) module%client Tabbable : sig From fd38f0dfdc87b958c76fd1209c0aae1f2ca331cb Mon Sep 17 00:00:00 2001 From: Vincent Balat Date: Fri, 27 Feb 2026 11:40:23 +0100 Subject: [PATCH 10/10] Ot_form: add reactive_fieldset Co-Authored-By: Claude Opus 4.6 --- src/widgets/ot_form.eliom | 6 ++++++ src/widgets/ot_form.eliomi | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/widgets/ot_form.eliom b/src/widgets/ot_form.eliom index 51d86415..8fd9351b 100644 --- a/src/widgets/ot_form.eliom +++ b/src/widgets/ot_form.eliom @@ -816,6 +816,12 @@ let%shared int_input ?(min = 0) ?(max = max_int) ?(size = 2) initial_value = in buttons, value +(* -- Reactive fieldset ------------------------------------------- *) + +let%shared reactive_fieldset ?(a = []) ~disabled content = + let a = (a :> Html_types.fieldset_attrib attrib list) in + F.fieldset ~a:(R.filter_attrib (a_disabled ()) disabled :: a) content + (* -- Date and time inputs ---------------------------------------- *) let%shared parse_date s = diff --git a/src/widgets/ot_form.eliomi b/src/widgets/ot_form.eliomi index 204b9027..39c1cbaf 100644 --- a/src/widgets/ot_form.eliomi +++ b/src/widgets/ot_form.eliomi @@ -320,6 +320,16 @@ val%shared validate_as_int : string -> (int option, unit) result val%shared none_input_value : string (** The string ["-"], used as placeholder for empty optional int inputs. *) +(** {3 Reactive fieldset} *) + +val%shared reactive_fieldset : + ?a:[< Html_types.fieldset_attrib] attrib list + -> disabled:bool Eliom_shared.React.S.t + -> [< Html_types.fieldset_content] elt list + -> [> `Fieldset] elt +(** A fieldset that can be reactively disabled. + When disabled, all form elements inside are disabled by the browser. *) + (** {3 Date and time inputs} *) val%shared parse_date : string -> (int * int * int) option