Implementing select/onChange - Ideas to avoid dummy defaults?

In my other recent post lydell recommended using the change event for select elements. In my first go, I had gone with click events on the child option elements, because Html.Styled.Events doesn’t implement an onChange function. I was just trying to get it working and move on, and I was testing in Firefox, so when the click event seemed to work I didn’t think about it much further. It turns out that this doesn’t work in Chromium based browsers, though, so yesterday I dug into defining an event to make this work.

The onChange event I came up with was simple enough.

import Html.Styled as HS
import Html.Styled.Attributes as HSA
import Html.Styled.Events as HSE
import Json.Decode as JD

onChange
    : (String -> msg) {- values to messages -}
   -> HS.Attribute msg
onChange toMsg
    = HSE.on "change"
        <| JD.map toMsg HSE.targetValue

What stumped me for a little bit was how to define that function of string values to messages.
The messages the component receives are a discrete list, and I can generate the values from list indices, so I know that only certain strings are possible, but of course to define a valid Elm function, you have to handle any input string. I tried at first to get away with leaving the impossible case as a to do.

{-| A list of choices as a dropdown -}
choice
    : String             {- component label -}
   -> List (String, msg) {- choices, as a list of ( label text, message ) -}
   -> List (HS.Html msg)
choice label choices
    = let
        valLabelValMsg = List.indexedMap
            (\ix (lbl, msg) -> ((String.fromInt ix, lbl), (String.fromInt ix, msg)))
            choices
        valLabel = List.map Tuple.first valLabelValMsg
        valMsg = List.map Tuple.second valLabelValMsg
        getMsg = \val
            -> Tuple.second
             <| Maybe.withDefault (Debug.todo "No message for value; should not happen")
                <| List.head
                <| List.filter (\(v, _) -> v == val) valMsg
    in [
        commandLabel label
      , HS.select [HSA.class "control-background", onChange getMsg]
            <| List.map
                (\(val, lbl) ->
                    HS.option [HSA.value val] [HS.text lbl]
                )
                valLabel
    ]

This logged the to do error to the console every time the dropdown was clicked, though. It seems that just loading the Maybe.withDefault function triggers it, not just returning the default value. In the end, I got it working by passing in a new argument with a default value.

{-| A list of choices as a dropdown -}
choice
    : String             {- component label -}
   -> List (String, msg) {- choices, as a list of ( label text, message ) -}
   -> msg                {- default message value, only used in case of error -}
   -> List (HS.Html msg)
choice label choices defaultMsg
    = let
        valLabelValMsg = List.indexedMap
            (\ix (lbl, msg) -> ((String.fromInt ix, lbl), (String.fromInt ix, msg)))
            choices
        valLabel = List.map Tuple.first valLabelValMsg
        valMsg = List.map Tuple.second valLabelValMsg
        getMsg = \val
            -> Tuple.second
             <| Maybe.withDefault ("impossible", defaultMsg)
                <| List.head
                <| List.filter (\(v, _) -> v == val) valMsg
    in [
        commandLabel label
      , HS.select [HSA.class "control-background", onChange getMsg]
            <| List.map
                (\(val, lbl) ->
                    HS.option [HSA.value val] [HS.text lbl]
                )
                valLabel
    ]

It feels a bit odd to have to supply a new value just to satisfy a default that will realistically never be called. Perhaps this is just one of those things we have to live with as we work with the strongly typed ELM framework on top of the not strongly typed HTML base. It seems like this could be implemented in JavaScript without the default value, and an exception would never be thrown. I’m curious — does anyone have a better idea?

hey there !

I think that you have better solutions at hands to escape this trap, but to answer strictly to your question you can extract that first value quite easily:

choice label choices =
    case choices of
        [] -> HS.select [] []
        (_, defaultMsg) :: xs -> choiceWithMsg label choices defaultMsg

for the better ideas: I’d use a String for the value, so that toMsg: (String -> msg) is just my msg. The transofmations that you make to create the msg in the view, I’d do it the update instead if possible. I think it’s weird to pair a string and msg here.

At work we usually do selects like this:

viewSelect onSelected selectedValue options =
    Html.select
        [ Html.Events.on
            "change"
            (Html.Events.targetValue
                |> Decode.andThen
                    (\indexText ->
                        case String.toInt indexText |> Maybe.andThen (\index -> List.getAt index options) of
                            Just option ->
                                Decode.succeed (onSelected option.value)

                            Nothing ->
                                Decode.fail "Index not found"
                    )
            )
        ]
        (options
            |> List.indexedMap
                (\index option ->
                    Html.option
                        [ Html.Attributes.value (String.fromInt index)
                        , Html.Attributes.selected (option.value == selectedValue)
                        ]
                        [ Html.text option.name ]
                )
        )
1 Like

Thanks! Your example helped me realize that Json.Decode.Fail must be a thing that does a kind of runtime exception. The Json module doc and the tutorial chapter don’t seem to mention what happens if a failure isn’t handled, so I guess it didn’t click for me.

In my solution, I decided to keep the event definition separate. I’m pretty pleased with the result.

{-| A list of choices as a dropdown -}
choice
    : String             {- component label -}
   -> List (String, msg) {- choices, as a list of ( label text, message ) -}
   -> List (HS.Html msg)
choice label choices
    = let
        (labels, messages) = List.unzip choices
        (values, onChange) = onChangeDiscrete messages
    in [
        commandLabel label
      , HS.select [HSA.class "control-background", onChange]
            <| List.map2
                (\val lbl ->
                    HS.option [HSA.value val] [HS.text lbl]
                )
                values
                labels
    ]

{-| An onChange event handler for a list of discrete messages.

    It returns `(values_list, onChange_attribute)`. Use the values to set up the
    options available for the element the attribute is applied to.
 -}
onChangeDiscrete
    : List msg
   -> (List String, HS.Attribute msg)
onChangeDiscrete messages
    = let
        valMsg = List.indexedMap (\ix msg -> (String.fromInt ix, msg)) messages
        values = List.map Tuple.first valMsg
        getMsg val = Maybe.map Tuple.second
            <| List.head
            <| List.filter (\(v, _) -> v == val) valMsg
        attribute = HSE.on "change"
            <| JD.andThen
                (\val -> case val of
                    Just m -> JD.succeed m
                    Nothing -> JD.fail "No message for value"
                )
            <| JD.map getMsg HSE.targetValue
    in
        (values, attribute)

Aside: It looks like Decoder is something like Haskell’s MonadFail, with andThen corresponding to =<<. Not that I’m an expert in that, it just seems like a somewhat useful analogy.

Decoders in event handlers silently fail, resulting in no message produced. That’s how it works. Not sure if it’s documented anywhere. On the flip side, that makes it harder to debug your decoder sometimes.

1 Like

I usually keep this in my back pocket.

4 Likes

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.