diff --git a/css/ot_form.css b/css/ot_form.css new file mode 100644 index 00000000..48a27bf6 --- /dev/null +++ b/css/ot_form.css @@ -0,0 +1,226 @@ +/* ================================================================ */ +/* 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; +} + +/* ================================================================ */ +/* 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 */ +/* ================================================================ */ + +/* 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/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." diff --git a/src/widgets/ot_form.eliom b/src/widgets/ot_form.eliom index 6ed7e66d..8fd9351b 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,918 @@ * 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) + +(* -- 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 + 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)] -open Js_of_ocaml +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 -[%%client open Js_of_ocaml_lwt] +(* -- Checkboxes -------------------------------------------------- *) -open Eliom_content.Html -open Eliom_content.Html.F +type%shared checkbox_position = [`Left | `Right | `None] +type%shared checkbox_style = [`Box | `Bullet | `Small_bullet | `Toggle] -class type tabbable = object - inherit Dom_html.element - method tabIndex : int Js.prop +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 + +(* -- Validation -------------------------------------------------- *) + +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)] + +(* -- Reactive select --------------------------------------------- *) + +let%shared reactive_select ?(a = []) ~options ?selected () = + let initial = + match selected with Some v -> v | None -> fst (List.hd options) + in + let signal, set_signal = Eliom_shared.React.S.create initial in + let make_option (value, label_text) = + let a_sel = if value = initial then [a_selected ()] else [] in + F.option ~a:(a_value value :: a_sel) (F.txt label_text) + in + let elt = + D.Raw.select + ~a: + (a_class ["ot-form-select"] + :: (a :> Html_types.select_attrib attrib list)) + (List.map make_option options) + in + let (_ : unit Eliom_client_value.t) = + [%client + let dom = + (Js.Unsafe.coerce (To_dom.of_element ~%elt) + : < value : Js.js_string Js.t Js.prop > Js.t) + in + Lwt.async (fun () -> + Lwt_js_events.changes (To_dom.of_element ~%elt) @@ fun _ _ -> + ~%set_signal (Js.to_string dom##.value); + Lwt.return_unit); + Eliom_lib.Dom_reference.retain (To_dom.of_element ~%elt) + ~keep: + (React.S.map + (fun v -> + if Js.to_string dom##.value <> v then dom##.value := Js.string v) + ~%signal)] + 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 () = + 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 = + 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 = "-" + +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) + +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 + +(* -- 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 = + 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) *) +(* ================================================================ *) + +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 +938,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 +959,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 +975,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..39c1cbaf 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,370 @@ * 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 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 : + ?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 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 : + ?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]. *) + +(** {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 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 : + ?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 : + ?a:[< Html_types.select_attrib] attrib list + -> options:(string * string) list + -> ?selected:string + -> unit + -> [> `Select] elt * string react_component +(** [reactive_select ~options ()] creates a [