Builder pattern for UI in Elm

Elm goodness.

If you stick this in the playground, it type checks and works.

module Main exposing (main)

import Browser
import Html exposing (Attribute, Html)
import Html exposing (div)
import Html.Attributes exposing (class)
import Html.Events



main =
  Browser.sandbox { init = init (), update = update, view = view }

-- Builder

type Builder m = Builder
  { node : String
  , attrs : List (Attribute m)
  , children : List (Html m)
  }

build : String -> List (Attribute m) -> List (Html m) -> Builder m
build node ass cs = Builder { node = node, attrs = ass, children = cs}

attrs : List (Attribute m) -> Builder m -> Builder m
attrs ass (Builder b) = Builder {b | attrs = b.attrs ++ ass}

children : (List (Html m) -> List (Html m)) -> Builder m -> Builder m
children f (Builder b) = Builder {b | children = f b.children}

done : Builder m -> Html m
done (Builder b)
  = Html.node b.node b.attrs b.children

map : (m -> n) -> Builder m -> Builder n
map f (Builder b) =
  let
    newAttrs = List.map (Html.Attributes.map f) b.attrs
    newChildren = List.map (Html.map f) b.children
  in Builder { node = b.node, attrs = newAttrs, children = newChildren}

myDiv : () -> Html m
myDiv _ =
  build "div" [] []
  |> attrs [class "myClass"]
  |> children (\kids -> [div [class "wrapper"] kids])
  |> done

input : String -> (String -> m) -> Builder m
input value onInput =
  build "input" [Html.Attributes.value value, Html.Events.onInput onInput] []

-- Generated code

type Form = Form
  { name : String
  , age : String
  }

newForm : {name : String, age : String} -> Form
newForm f = Form f

type Field
  = Name
  | Age


-- exposed getter so client can react to this
get : Field -> Form -> String
get f (Form form) = case f of
    Name -> form.name
    Age -> form.age

type alias FormMsg = {field: Field, string: String}

updateForm : Form -> FormMsg -> Form
updateForm (Form old) ({field, string}) =
  case field of
      Name -> Form {old | name = string}
      Age -> Form {old | age = string}

-- have to pass in form to handle state, but you can have a configurable component
viewField : Form -> Field -> Builder FormMsg
viewField (Form form) f = case f of
    Name -> input form.name (FormMsg f) |> attrs [class "input-name"]
    Age -> input form.age (FormMsg f) |> attrs [class "input-age"]

-- Client (consuming the generated code)

type alias Model = { form : Form }

type Msg
  = Clicked -- Another component alongside the form
  | Child FormMsg

init : () -> Model
init _ = { form = newForm {name = "Jack", age = "28"}}

update : Msg -> Model -> Model
update msg model =
  case msg of
      Clicked -> model
      Child childMsg -> {model | form = updateForm model.form childMsg}

wrapChildMsg : FormMsg -> Msg
wrapChildMsg m = Child m

view : Model -> Html Msg
view {form} =
  let
    nameInput = viewField form Name
      |> attrs [class "subtle"]
      |> map wrapChildMsg
      |> done
    ageInput = viewField form Age
      |> attrs [class "subtle"]
      |> map wrapChildMsg
      |> done
  in
    div 
      [class "container"]
      [ nameInput
      , ageInput
      ]