Controlled number input with Floats in Elm

joanllenas

Joan Llenas Mas贸

Posted on July 18, 2018

Controlled number input with Floats in Elm

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
        }
Enter fullscreen mode Exit fullscreen mode

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 the string -> float conversion.
  • You can't type negative numbers because the negative symbol - is stripped after the string -> float conversion.

Research

I have found a few solutions to this problem:

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 a String.
  • 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
        }
Enter fullscreen mode Exit fullscreen mode

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) ]
            ]
        ]

-- (...)
Enter fullscreen mode Exit fullscreen mode

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.

馃挅 馃挭 馃檯 馃毄
joanllenas
Joan Llenas Mas贸

Posted on July 18, 2018

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related