A typesafe form package for Elm that lets you really use the platform.
⚠️ This is alpha, severely under-documented, and has zero tests - so please proceed with caution, for now. ⚠️
But with all that said - this is the form package that you want. The form package that you didn't realize could exist.
The Html.Form.Module instance gives you access to a record with named fields that allows you to access:
You can implement this in an existing application by applying the element's attributes to whatever inputs you have already - or you can build up elements yourself and hoist the form events into them - or you can simply take the element outputs and style them however you want. It's up to you, really.
It also gives you access to a list of your whole form's errors, tupled with their "editor" value - which you can easily restructure into a record with named properties, if you'd like.
Oh, and none of this is using functions in the Model, or any black magic. At time of writing, the only dependencies are on packages from the elm Github org.
More documentation to come soon, but here's a taste of the API - this is all copied from this repo's demo/src/Main.elm and demo/src/Signup.elm modules.
If you clone the repo, you can run the sample with pnpm start.
Forgive this; I'm not very good at HTML. But at least you can get an idea of what your callsites could look like! (I'm going to clean this up later.)
view : Model -> Html Msg
view model =
let
fieldset : Signup.Fieldset Msg
fieldset =
signupModule.fieldset model
margin : Int -> Html.Attribute msg
margin i =
String.fromInt i
++ "px"
|> Html.Attributes.style "margin"
withLabel l field =
Html.div [ margin 12 ]
[ Html.label [ margin 4 ]
[ Html.text l
, field
]
]
withCallback field =
Html.div [ Html.Events.onInput GotPropagatedEvent ] [ field ]
withErrors field attrs =
let
showErrors =
not <| List.isEmpty field.errors
in
Html.div []
[ field.element <|
if showErrors then
Html.Attributes.style "text-decoration-style" "wavy"
:: Html.Attributes.style "text-decoration-line" "underline"
:: Html.Attributes.style "text-decoration-color" "red"
:: attrs
else
attrs
, if showErrors then
Html.div [] [ Html.ul [] <| Html.h4 [] [ Html.text "Errors" ] :: List.map (\err -> Html.li [] [ Html.text err ]) field.errors ]
else
Html.text ""
]
in
Html.div []
[ Html.div []
[ Html.div []
[ withErrors fieldset.name [ margin 12 ] |> withLabel "Name"
, Html.input (fieldset.age.toAttrs [ margin 12 ]) [] |> withLabel "Age"
, withErrors fieldset.emailAddress [ margin 12 ] |> withLabel "Email Address"
, fieldset.subscribe.element [ Html.Attributes.style "margin" "4px" ] |> withLabel "Subscribe"
]
, Html.button [ Html.Events.onClick signupModule.submitMsg ] [ Html.text "Submit" ]
, Html.hr [] []
, Debug.toString model |> Html.text
, Html.hr [] []
, Debug.toString (signupModule.errors model) |> Html.text
, Html.hr [] []
, Debug.toString (signupModule.errors model |> Signup.fromErrors) |> Html.text
]
]This is how you initialize a Html.Form.Module.
import Html
import Html.Form
import Html.Form.Validation
{-| First, you create a type to represent a single field's editing state.
-}
type Editor
= Name (Maybe String)
| Age (Maybe Int)
| EmailAddress (Maybe String)
| Subscribe (Maybe Bool)
{-| Then, you create a type to represent a validated, submitted value.
-}
type alias Record =
{ name : String
, age : Int
, emailAddress : String
, subscribe : Bool
}
{-| You can use custom types for errors, if you want to!
-}
type alias Error =
String
{-| Then, you create a type to represent your output, from the form module.
-}
type alias Fieldset msg =
{ name : Html.Form.Field Error msg
, age : Html.Form.Field Error msg
, emailAddress : Html.Form.Field Error msg
, subscribe : Html.Form.Field Error msg
}
{-| Now, you provide a map from a `List` of your `Editor` type, to a `Maybe Record`.
(Note: Don't put your validation in here - that's not where it goes!)
-}
toRecord : List Editor -> Maybe Record
toRecord editors =
List.foldl
(\step acc ->
case step of
Name name ->
{ acc | name = name }
Age age ->
{ acc | age = age }
EmailAddress emailAddress ->
{ acc | emailAddress = emailAddress }
Subscribe subscribe ->
{ acc | subscribe = subscribe }
)
{ name = Nothing
, age = Nothing
, emailAddress = Nothing
, subscribe = Nothing
}
editors
|> (\e -> Maybe.map4 Record e.name e.age e.emailAddress e.subscribe)
{-| Now, you can initialize the module. (In this sample, I'm defining my form in one module and actually
hosting it in another, so `toMsg` and `onSubmit` are parameterized.)
-}
form :
{ toMsg : Html.Form.Msg Editor -> msg, onSubmit : Record -> msg }
-> Html.Form.Module String Editor { model | signupForm : Html.Form.Model Editor } (Fieldset msg) msg
form { toMsg, onSubmit } =
{-
Mapping the fields to the `Fieldset msg` works similiarly to `Json.Decode.Pipeline`, where you use a constructor function
(usually the type alias constructor from a record type alias) to kick off an applicative.
-}
Html.Form.init Fieldset
{ toModel = \m formModel -> { m | signupForm = formModel }
, fromModel = .signupForm
, toMsg = toMsg
, toRecord = toRecord
, onSubmit = onSubmit
}
-- You can then add as many fields as you have parameters to your constructor function.
|> Html.Form.withField Name
(Html.Form.input
{-
You can allow propagation on inputs so that the event bubbles; you can use this
to fire events in a host module when something happens in your form - per field!
-}
|> Html.Form.withStopPropagation False
{-
You can add validation!
-}
|> Html.Form.withValidation
(Html.Form.Validation.when.blurredAfterEdit
(\args ->
if args.self == Name (Just "John") then
Err "I'm sorry, John; you can't do that."
else
Ok args.self
)
)
)
|> Html.Form.withField (Maybe.andThen String.toInt >> Age) Html.Form.input
|> Html.Form.withField EmailAddress
(Html.Form.input
|> Html.Form.withInitialValue (Just "[email protected]")
|> Html.Form.withValidation
(Html.Form.Validation.when.editingOrBlurred
(\args ->
case args.self of
EmailAddress (Just v) ->
if v == "" then
Err "Email address must not be empty"
else
Ok args.self
_ ->
Err "Email address must not be empty"
)
|> Html.Form.Validation.andThen
(\args ->
case args.self of
EmailAddress (Just v) ->
if not <| String.contains "@" v then
Err "Email addresses must contain an @ symbol"
else
Ok args.self
_ ->
Ok args.self
)
)
)
|> Html.Form.withField Subscribe (Html.Form.checkbox |> Html.Form.withStopPropagation False)
|> Html.Form.build