DEV Community

Joan Llenas Masó
Joan Llenas Masó

Posted on • Edited on

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.

Top comments (9)

Collapse
 
milesfrain profile image
milesfrain

There's a bug where you cannot enter numbers with any zeros after the decimal point. For example, typing 1.05 becomes 15.

This can be fixed by changing:

PriceField (Just price) _ ->
    toString price

to:

PriceField (Just _) price ->
    price

Here's a link to the code with that fix and updated to elm 0.19
ellie-app.com/6p5jwch2pYpa1

Collapse
 
joanllenas profile image
Joan Llenas Masó

Good catch @milesfrain ,
Do you mind if I update the post with the bugfix + the 0.19 code?
Cheers!

Collapse
 
milesfrain profile image
milesfrain

Feel free to update the post with those code changes

Collapse
 
ben profile image
Ben Halpern

Ooh we should definitely make Ellie embeds via liquid tags 😄

Anything we should know besides just using the available embed code @lukewestby ?

Collapse
 
lukewestby profile image
Luke Westby

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.

Collapse
 
ben profile image
Ben Halpern

Wonderful 🙂

Thread Thread
 
lukewestby profile image
Luke Westby

😳Looks like the oembed endpoint is pretty broken right now. I can get that fixed by tomorrow if you need it.

Thread Thread
 
ben profile image
Ben Halpern

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.

Thread Thread
 
joanllenas profile image
Joan Llenas Masó

Looking forward to refactoring those gists into first class ellie embeds 🙂