The term controlled is taken from the React documentation, but it's also applicable to Elm.
In the context of forms controlled means that the model manages the input field value so, when we type something, the change is propagated to the model and, immediately after that, the input value is also updated.
The Problem
Controlled inputs work well when the value they hold is a string, but we can't state the same when the value is a number.
Contrived example
( Ellie link )
module Main exposing (..)
import Html exposing (..)
import Html.Attributes as Attrs exposing (..)
import Html.Events exposing (..)
type Msg
= SetPrice String
type alias Model =
{ price : Float }
model : Model
model =
{ price = 0 }
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
SetPrice price ->
( { model | price = price |> String.toFloat |> Result.withDefault 0 }
, Cmd.none
)
view : Model -> Html Msg
view model =
div []
[ Html.form []
[ label [] [text "Price"]
, input [placeholder "Price", value (toString model.price), onInput SetPrice ] []
, br [] []
, p [] [ text ("Price is: " ++ (toString model.price)) ]
]
]
main : Program Never Model Msg
main =
Html.program
{ init = ( model, Cmd.none )
, update = update
, subscriptions = \_ -> Sub.none
, view = view
}
The main issue here is that the string -> float
conversion only allows valid numbers, so any intermediate step that creates an invalid number gets converted to 0
.
Issues with this approach
- You can't have an empty input field, it always displays
0
. - When you type a non-numeric character by mistake, the whole number is deleted.
- You can't type decimals because the decimal separator
.
is stripped after thestring -> float
conversion. - You can't type negative numbers because the negative symbol
-
is stripped after thestring -> float
conversion.
Research
I have found a few solutions to this problem:
- Html Number Form Inputs (https://discourse.elm-lang.org/t/html-number-form-inputs/740)
- Various solutions for solving the Elm Guide age exercise (http://www.bravo-kernel.com/2016/06/various-solutions-for-solving-the-elm-guide-age-exercise/)
- Use
defaultValue
instead ofvalue
.
But all of them have failed to satisfy my needs.
My contribution
My solution must satisfy the following:
- The input type must be
text
because I have to support iOS and Android, where the input type number support is poor. - The record value that feeds the input must be a
Float
, not aString
. - The input field may be empty.
- All that and, ultimately, fix all the typing issues stated above.
( Ellie link )
module Main exposing (..)
import Html exposing (..)
import Html.Attributes as Attrs exposing (..)
import Html.Events exposing (..)
type Msg
= SetPrice String
type PriceField
= PriceField (Maybe Float) String
type alias Model =
{ price : PriceField }
model : Model
model =
{ price = PriceField Nothing "" }
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
SetPrice price ->
if String.right 1 price == "." then
( { model | price = PriceField Nothing price }, Cmd.none )
else
let
maybePrice =
price |> String.toFloat |> Result.toMaybe
in
case maybePrice of
Nothing ->
( { model | price = PriceField Nothing price }, Cmd.none )
Just p ->
( { model | price = PriceField (Just p) price }, Cmd.none )
priceFieldToString : PriceField -> String
priceFieldToString priceField =
case priceField of
PriceField Nothing price ->
price
PriceField (Just price) _ ->
toString price
view : Model -> Html Msg
view model =
div []
[ Html.form []
[ label [] [ text "Price" ]
, input [ placeholder "Price", value (priceFieldToString model.price), onInput SetPrice ] []
, br [] []
, small [] [ text ("Price is: " ++ toString model.price) ]
]
]
main : Program Never Model Msg
main =
Html.program
{ init = ( model, Cmd.none )
, update = update
, subscriptions = \_ -> Sub.none
, view = view
}
Recap
The key here is the PriceField
product type which allows us to have both the input String
value and the Maybe Float
at the same place.
We can also use the Maybe Float
as an indicator of invalid input state, so we can add custom validation logic:
( Ellie link - same solution with some basic validation )
-- (...)
priceValidationStyle : PriceField -> List ( String, String )
priceValidationStyle priceField =
case priceField of
PriceField Nothing price ->
if price == "" then
[]
else
[ ( "background-color", "red" ) ]
PriceField (Just price) _ ->
[]
view : Model -> Html Msg
view model =
div []
[ Html.form []
[ label [] [ text "Price" ]
, input [ placeholder "Price", value (priceFieldToString model.price), onInput SetPrice, style (priceValidationStyle model.price) ] []
, br [] []
, small [] [ text ("Price is: " ++ toString model.price) ]
]
]
-- (...)
I can imagine how this same strategy could be used to add more advanced validation information to the same type, but this is all I have for now.
If you know any, I'd love to hear what other strategies or packages can be used to achieve similar goals.
Top comments (9)
There's a bug where you cannot enter numbers with any zeros after the decimal point. For example, typing
1.05
becomes15
.This can be fixed by changing:
to:
Here's a link to the code with that fix and updated to elm 0.19
ellie-app.com/6p5jwch2pYpa1
Good catch @milesfrain ,
Do you mind if I update the post with the bugfix + the 0.19 code?
Cheers!
Feel free to update the post with those code changes
Ooh we should definitely make Ellie embeds via liquid tags 😄
Anything we should know besides just using the available embed code @lukewestby ?
Awesome! If it's useful to you there is also an oembed endpoint, e.g. ellie-app.com/oembed?url=ellie-app...
If anything doesn't seem to be working I'm available whenever to help troubleshoot.
Wonderful 🙂
😳Looks like the oembed endpoint is pretty broken right now. I can get that fixed by tomorrow if you need it.
Cool. Happy to use the other tag, but I'll give it some thought and let you know if we do. We'll be prompt in implementing this but no major rush on getting it worked out.
Looking forward to refactoring those gists into first class ellie embeds 🙂