Joan Llenas Mas贸
Posted on July 18, 2018
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.
Posted on July 18, 2018
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.