diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b51309 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +elm-stuff +.*.sw? +*~ +elm.js +index.html +docs.json +documentation.json +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f0ac167 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2016, Ville Penttinen +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the elm-dispatch nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..42499ae --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# elm-dispatch + +Makes it easier to dispatch multiple messages from a single `Html.Event`. + +The library was developed for the purpose of allowing UI component libraries, such as [elm-mdl](http://package.elm-lang.org/packages/debois/elm-mdl/latest), to have stateful components that perform some internal actions on `Html.Events` such as `click`, `focus` and `blur` while still allowing users to have their own event handlers for those particular events as well. + + +## Install + +```shell +elm package install vipentti/elm-dispatch +``` + +## Examples + +To see the library in action see [elm-mdl](http://package.elm-lang.org/packages/debois/elm-mdl/latest) specifically [Material.Options.Internal](https://github.com/debois/elm-mdl/blob/master/src/Material/Options/Internal.elm). + +An example may also be found in `examples/` + +## Basic Usage + +To add support for Dispatch: + +Add a dispatch message to your `Msg` +```elm +type Msg + = ... + | Dispatch (List Msg) + ... +``` + +Add call to `Dispatch.forward` in update +```elm +update : Msg -> Model -> (Model, Cmd Msg) +update msg model = + case msg of + ... + + Dispatch messages -> + model ! [ Dispatch.forward messages ] + + ... +``` + +Add a call to `Dispatch.on` on an element +```elm +view : Model -> Html Msg +view model = + let + decoders = + [ Json.Decode.succeed Click + , Json.Decode.succeed PerformAnalytics + , Json.Decode.map SomeMessage + (Json.at ["target", "offsetWidth"] Json.float) ] + in + Html.button + [ Dispatch.on "click" Dispatch decoders ] + [ text "Button" ] +``` + +For more advanced use see `examples/`. \ No newline at end of file diff --git a/elm-package.json b/elm-package.json new file mode 100644 index 0000000..f9696fb --- /dev/null +++ b/elm-package.json @@ -0,0 +1,18 @@ +{ + "version": "1.0.0", + "summary": "Allow for dispatching multiple messages from a single Html event", + "repository": "https://github.com/vipentti/elm-dispatch.git", + "license": "BSD3", + "source-directories": [ + "src", + "examples" + ], + "exposed-modules": [ + "Dispatch" + ], + "dependencies": { + "elm-lang/core": "4.0.5 <= v < 5.0.0", + "elm-lang/html": "1.1.0 <= v < 2.0.0" + }, + "elm-version": "0.17.1 <= v < 0.18.0" +} diff --git a/examples/FancyButton.elm b/examples/FancyButton.elm new file mode 100644 index 0000000..afe3c71 --- /dev/null +++ b/examples/FancyButton.elm @@ -0,0 +1,206 @@ +module FancyButton + exposing + ( Msg + , Model + , Property + , model + , update + , view + , clickCount + , any + , on + , on1 + , onClick + ) + +import Html exposing (..) +import Html.Attributes exposing (style) +import Dispatch +import Json.Decode as Json + + +-- MODEL +{-| Model is opaque as it contains internal state. + -} +type Model + = Model + { focused : Bool + , clickCount : Int + } + +{-| Initialize the model + -} +model : Model +model = + Model + { focused = False + , clickCount = 0 + } + + +{-| Utility function to access some internal state + -} +clickCount : Model -> Int +clickCount (Model { clickCount }) = + clickCount + + + +-- Properties + +{-| FancyButton only accepts specific +types of properties + -} +type Property msg + = Decoder String (Json.Decoder msg) + | Any (Html.Attribute msg) + + +{-| Add an `Html.Event` handler + -} +on : String -> Json.Decoder msg -> Property msg +on = + Decoder + + +{-| Add an `Html.Event` handler. + +Equivalent to `FancyButton.on "event" (Json.succeed msg)` + -} +on1 : String -> msg -> Property msg +on1 evt msg = + on evt (Json.succeed msg) + + +{-| Add an onClick handler. + -} +onClick : msg -> Property msg +onClick msg = + on1 "click" msg + + +{-| Map from Html.Attribute to a FancyButton.Property + -} +any : Html.Attribute msg -> Property msg +any = + Any + + + +-- UPDATE + + +type Msg msg + = Click + | Focus + | Blur + {- This message tells Dispatch how to + convert a list of messages to a single message + -} + | Dispatch (List msg) + + +update : Msg msg -> Model -> ( Model, Cmd msg ) +update msg (Model model) = + case msg of + {- Forward all the messages produced by handlers with multiple decoders + attached to them + -} + Dispatch msg' -> + Model model ! [ Dispatch.forward msg' ] + + Click -> + Model { model | clickCount = model.clickCount + 1 } ! [] + + Focus -> + Model { model | focused = True } ! [] + + Blur -> + Model { model | focused = False } ! [] + + + +-- VIEW + + +view : (Msg msg -> msg) -> Model -> List (Property msg) -> List (Html msg) -> Html msg +view lift (Model model) props content = + let + {- We want to perform internal actions on these events + -} + defaultListeners = + [ on1 "mouseenter" (lift Focus) + , on1 "mouseleave" (lift Blur) + , on1 "click" (lift Click) + ] + + {- Setup the Dispatch configuration using user provided events as well as + our own internal events + -} + config = + List.foldl + (\prop acc -> + case prop of + Decoder evt d -> + Dispatch.add evt Nothing d acc + + Any attribute -> + acc + ) + (Dispatch.setMsg (Dispatch >> lift) Dispatch.defaultConfig) + (props ++ defaultListeners) + + {- Don't add listeners here, + they are already added in the config + -} + attributes = + List.map + (\prop -> + case prop of + Decoder _ _ -> + Nothing + + Any a -> + Just a + ) + props + |> List.filterMap identity + in + button + ([ normal + , if model.focused then + focused + else + style [] + ] + ++ attributes + ++ (Dispatch.toAttributes config) + ) + content + + + +-- STYLES + + +normal : Attribute a +normal = + style + [ ( "display", "inline-block" ) + , ( "margin", "0 10px 0 0" ) + , ( "padding", "15px 15px" ) + , ( "font-size", "16px" ) + , ( "line-height", "1.8" ) + , ( "appearance", "none" ) + , ( "box-shadow", "none" ) + , ( "border-radius", "0" ) + ] + + +focused : Attribute a +focused = + style + [ ( "background-color", "#b6d8e4" ) + , ( "text-shadow", "-1px 1px #27496d" ) + , ( "outline", "none" ) + ] diff --git a/examples/elm-package.json b/examples/elm-package.json new file mode 100644 index 0000000..7c68c7a --- /dev/null +++ b/examples/elm-package.json @@ -0,0 +1,16 @@ +{ + "version": "1.0.0", + "summary": "elm-dispatch examples", + "repository": "https://github.com/vipentti/elm-dispatch.git", + "license": "BSD3", + "source-directories": [ + ".", + "../src" + ], + "exposed-modules": [], + "dependencies": { + "elm-lang/core": "4.0.5 <= v < 5.0.0", + "elm-lang/html": "1.1.0 <= v < 2.0.0" + }, + "elm-version": "0.17.1 <= v < 0.18.0" +} diff --git a/examples/example-1.elm b/examples/example-1.elm new file mode 100644 index 0000000..367143e --- /dev/null +++ b/examples/example-1.elm @@ -0,0 +1,117 @@ +module Main exposing (..) + +import Html exposing (Html, div, text) +import Html.App as App +import Html.Attributes exposing (style) +import Dict exposing (Dict) +import FancyButton + + +main : Program Never +main = + App.program + { init = init + , update = update + , view = view + , subscriptions = always Sub.none + } + + +init : ( Model, Cmd Msg ) +init = + model ! [] + + +type alias Model = + { buttons : Dict Int FancyButton.Model + , activeButton : Int + , lastClick : Int + } + + +model : Model +model = + { buttons = Dict.empty + , activeButton = -1 + , lastClick = -1 + } + + +get : Int -> Dict Int FancyButton.Model -> FancyButton.Model +get idx dict = + Dict.get idx dict + |> Maybe.withDefault FancyButton.model + + +button : + Int + -> { a | buttons : Dict Int FancyButton.Model } + -> Html Msg +button idx model = + let + buttonModel = + (get idx model.buttons) + + clicks = + FancyButton.clickCount buttonModel + |> toString + in + FancyButton.view (FancyButton idx) + buttonModel + [ FancyButton.on1 "mouseenter" (MouseEnter idx) + , FancyButton.on1 "mouseleave" (MouseLeave idx) + , FancyButton.onClick (Click idx) + ] + [ text <| "FancyButton " ++ (toString idx) ++ " (Internal Click Count " ++ clicks ++ ")" ] + + +type Msg + = Click Int + | MouseEnter Int + | MouseLeave Int + | FancyButton Int (FancyButton.Msg Msg) + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + Click idx -> + { model | lastClick = idx } ! [] + + MouseEnter index -> + { model | activeButton = index } ! [] + + MouseLeave _ -> + { model | activeButton = -1 } ! [] + + FancyButton index msg' -> + let + ( u, c ) = + FancyButton.update msg' (get index model.buttons) + in + { model | buttons = Dict.insert index u model.buttons } ! [ c ] + + +view : Model -> Html Msg +view model = + div + [ style [ ( "margin", "20px" ) ] + ] + [ button 0 model + , button 1 model + , button 2 model + , div + [] + [ if model.activeButton >= 0 then + text ("Mouse is over button: " ++ toString model.activeButton) + else + text "\xA0" -- Maps to   + ] + , div + [] + [ if model.lastClick >= 0 then + text ("Last button clicked: " ++ toString model.lastClick) + else + text "\xA0" -- Maps to   + ] + ] diff --git a/src/Dispatch.elm b/src/Dispatch.elm new file mode 100644 index 0000000..cc03d2c --- /dev/null +++ b/src/Dispatch.elm @@ -0,0 +1,276 @@ +module Dispatch exposing + ( Config, toAttributes, add, setMsg, setDecoder, getDecoder, defaultConfig + , clear + , on, onWithOptions + , update + , forward + ) + +{-| Utility module for applying multiple decoders to a single `Html.Event`. + +## Events +@docs on +@docs onWithOptions +@docs update +@docs forward + +## Utilities + +These are tailored for writing UI component libraries +with stateful components, such as `elm-mdl`. + +@docs Config, defaultConfig, setDecoder, getDecoder, setMsg, toAttributes +@docs add +@docs clear + +-} + +import Json.Decode as Json exposing (Decoder) +import Html.Events +import Html +import Task + + +-- CONFIG + +{-| Dispatch configuration type + -} +type Config msg = + Config + { decoders : List (String, (Decoder msg, Maybe Html.Events.Options)) + , lift : Maybe (Decoder (List msg) -> Decoder msg) + } + + +{-| Empty configuration + -} +defaultConfig : Config msg +defaultConfig = + Config + { decoders = [] + , lift = Nothing + } + + +{-| Tell Dispatch how to convert a list of decoders into a decoder for a single message. +-} +setDecoder : (Decoder (List msg) -> Decoder msg) -> Config msg -> Config msg +setDecoder f (Config config) = + Config { config | lift = Just f } + + +{-| Tell Dispatch how to convert a list of messages into a single message. Alternative +to `setDecoder`. +-} +setMsg : (List msg -> msg) -> Config msg -> Config msg +setMsg = + Json.map >> setDecoder + + +{-| Get the Dispatch message constructor +-} +getDecoder : Config msg -> Maybe (Decoder (List msg) -> Decoder msg) +getDecoder (Config config) = + config.lift + + +{-| Add an event-handler to the current configuration + -} +add : String -> Maybe Html.Events.Options -> Decoder msg -> Config msg -> Config msg +add event options decoder (Config config) = + Config + { config | decoders = (event, (decoder, options)) :: config.decoders } + + +{-| Clear event handlers in current configuration +-} +clear : Config msg -> Config msg +clear (Config config) = + Config + { config | decoders = [] } + + +{-| Returns a list of `Html.Attribute` containing handlers that +dispatch multiple decoders on a single `Html.Event` + -} +toAttributes : Config msg -> List (Html.Attribute msg) +toAttributes (Config config) = + case config.lift of + Just f -> + List.map (onMany f) (group config.decoders) + Nothing -> + List.map onSingle config.decoders + + +{-| Promote `msg` to `Cmd msg` +-} +cmd : msg -> Cmd msg +cmd msg = + Task.perform (always msg) (always msg) (Task.succeed msg) + + +-- UPDATE + + +{-| Maps messages to commands +-} +forward : (List msg) -> Cmd msg +forward messages = + List.map cmd messages |> Cmd.batch + + +{-| Map the second element of a tuple + + map2nd ((+) 1) ("bar", 3) == ("bar", 4) +-} +map2nd : (b -> c) -> ( a, b ) -> ( a, c ) +map2nd f ( x, y ) = + ( x, f y ) + + +update1 : (m -> model -> (model, d)) -> m -> ( model, List d ) -> ( model, List d ) +update1 update cmd ( m, gs ) = + update cmd m + |> map2nd (flip (::) gs) + + +{-| Runs the given `update` on all the messages and +returns the updated model including batching of +any commands returned by running the `update`. +-} +update : (msg -> model -> (model, Cmd obs)) -> (List msg) -> model -> (model, Cmd obs) +update update msg model = + List.foldl (update1 update) (model, []) msg + |> map2nd Cmd.batch + + +-- VIEW + + +decode : List (Decoder m) -> Json.Value -> Result c (List m) +decode decoders v0 = + List.filterMap (flip Json.decodeValue v0 >> Result.toMaybe) decoders + |> Result.Ok + + +{-| Applies given decoders to the same initial value + and return the applied results as a list +-} +flatten : List (Decoder m) -> Decoder (List m) +flatten = + decode >> Json.customDecoder Json.value + + +{-| Dispatch multiple decoders for a single event. + -} +on + : String + -> (List msg -> msg) + -> List (Decoder msg) + -> Html.Attribute msg +on event lift = + onWithOptions event lift Html.Events.defaultOptions + + +{-| Dispatch multiple decoders for a single event. +Options apply to the whole event. + -} +onWithOptions + : String + -> (List msg -> msg) + -> Html.Events.Options + -> List (Decoder msg) + -> Html.Attribute msg +onWithOptions event lift options decoders = + flatten decoders + |> Json.map lift + |> Html.Events.onWithOptions event options + + +{-| Run multiple decoders on a single Html Event with +the given options +-} +onMany + : (Decoder (List m) -> Decoder m) + -> ( String, List ( Decoder m, Maybe Html.Events.Options ) ) + -> Html.Attribute m +onMany lift decoders = + case decoders of + -- Install direct handler for singleton case + (event, [ decoder ]) -> + onSingle (event, decoder) + + (event, decoders) -> + flatten (List.map fst decoders) + |> lift + |> Html.Events.onWithOptions event (pickOptions decoders) + + +pickOptions : List ( a, Maybe Html.Events.Options ) -> Html.Events.Options +pickOptions decoders = + case decoders of + (_, Just options) :: _ -> options + _ :: rest -> pickOptions rest + [] -> Html.Events.defaultOptions + + +onSingle + : (String, (Decoder m, Maybe Html.Events.Options )) + -> Html.Attribute m +onSingle (event, (decoder, option)) = + Html.Events.onWithOptions + event + (Maybe.withDefault Html.Events.defaultOptions option) + decoder + + +-- UTILITIES + + +{-| Group a list of pairs based on the first item. Optimised for lists of size +< 10 with < 3 overlaps. +-} +group : List ( a, b ) -> List ( a, List b ) +group = + group' [] + + +split : + a + -> List b + -> List ( a, b ) + -> List ( a, b ) + -> ( List b, List ( a, b ) ) +split k0 same differ xs = + case xs of + [] -> + ( same, differ ) + + (( k, v ) as x) :: xs -> + if k == k0 then + split k0 (v :: same) differ xs + else + split k0 same (x :: differ) xs + + +group' : List ( a, List b ) -> List ( a, b ) -> List ( a, List b ) +group' acc items = + case items of + [] -> + acc + + [ ( k, v ) ] -> + ( k, [ v ] ) :: acc + + [ ( k1, v1 ), ( k2, v2 ) ] -> + if k1 == k2 then + ( k1, [ v2, v1 ] ) :: acc + else + ( k2, [ v2 ] ) :: ( k1, [ v1 ] ) :: acc + + ( k, v ) :: xs -> + let + ( same, different ) = + split k [ v ] [] xs + in + group' (( k, same ) :: acc) different