diff --git a/README.md b/README.md index a2706e8..c01d2f4 100644 --- a/README.md +++ b/README.md @@ -1,418 +1,90 @@ # elm-slider +A range slider component, implemented natively in Elm. + +## Installation + ```shell elm install carwow/elm-slider ``` ## Usage -You can create a double slider model which handles values from `min` to `max` with a `step`, providing two thumbs with with values `lowValue` and `highValue`. - -```elm - let - initialSliderModel = - DoubleSlider.defaultModel - in - { initialSliderModel - | min = 50 - , max = 5000 - , step = 50 - , lowValue = 50 - , highValue = 5000 - } -``` - -Default formatters for the `min`, `max` and `current range` will be applied unless custom formatters are provided as the following: - -```elm - let - initialSliderModel = - DoubleSlider.defaultModel - in - { initialSliderModel - | min = 50 - , max = 5000 - , step = 50 - , lowValue = 50 - , highValue = 5000 - , minFormatter = toString - , maxFormatter = toString - , currentRangeFormatter = customRangeFormatter - } -``` - -where: +For a full example implementation, see `examples/Main.elm` -```elm - customRangeFormatter : Float -> Float -> Float -> Float -> String - customRangeFormatter lowValue highValue min max = - ... -``` +There are two types of sliders that can be rendered, a `SingleSlider`, with one track thumb and a `DoubleSlider`, with two track thumbs. -You can create a single slider model which handles values from `min` to `max` with a `step` and a `value`. +Input handling is *your* responsibility - define a function to decided what to do when the slider's value changes. -```elm - let - initialSliderModel = - SingleSlider.defaultModel - in - { initialSliderModel - | min = 50 - , max = 5000 - , step = 50 - , value = 2000 - } -``` - -Default formatters for the `min`, `max` and `current value` will be applied unless custom formatters are provided as the following: +### SingleSlider Example ```elm - let - initialSliderModel = - SingleSlider.defaultModel - in - { initialSliderModel - | min = 50 - , max = 5000 - , step = 50 - , lowValue = 50 - , highValue = 5000 - , minFormatter = toString - , maxFormatter = toString - , currentValueFormatter = customValueFormatter +singleSlider = + SingleSlider.init + { min = 0 + , max = 1000 + , value = 500 + , step = 50 + , onChange = SingleSliderChange } -``` - -where: - -```elm - customValueFormatter : Float -> Float -> String - customValueFormatter currentValue max = - ... -``` + + +type Msg + = NoOp + | SingleSliderChange Float -Because it uses mouse movements, the range slider requires subscriptions. After initialization, handle the subscriptions. -```elm -subscriptions = - Sub.map SliderMsg <| - DoubleSlider.subscriptions model.slider +view : Model -> Html Msg +view model = + div [] + [ div [] [ SingleSlider.view model.singleSlider ] ] ``` -Handle the updates from the subscription in your main update function. Together with the new model and a command -the sliders update function returns also a boolean, which is false for all dragging updates and true when the -dragging stops. This is useful if you want to trigger expensive commands like api calls only after the dragging -has stopped. +### DoubleSliderExample ```elm -update : Msg -> Model -> ( Model, Cmd Msg ) -update msg model = - case msg of - SliderMsg innerMsg -> - let - ( newSlider, cmd, updateResults ) = - DoubleSlider.update innerMsg model slider - - newModel = - { model | slider = newSlider } - - newCmd = - if updateResults then - Cmd.batch [ Cmd.map SliderMsg cmd, otherCmd ] - else - otherCmd - in - ( newModel, newCmd ) -``` - -To view the slider, simply call the view function -```elm -DoubleSlider.view model.slider |> Html.map SliderMsg -``` - -## Example -```elm -module Thing exposing (init, update, subscriptions, view, Model, Msg) - -import Html exposing (Html, div, text) -import SingleSlider as Slider exposing (..) - -type alias Model = - { slider : Slider.Model - } - -slider : Slider.Model -slider = - let - initialSlider = - Slider.defaultModel - in - { initialSlider - | min = 0 - , max = 10 - , step = 1 - , value = 0 - } +doubleSlider = + DoubleSlider.init + { min = 0 + , max = 1000 + , lowValue = 500 + , highValue = 750 + , step = 50 + , onLowChange = DoubleSliderLowChange + , onHighChange = DoubleSliderHighChange + } -initialModel : Model -initialModel = - { slider = slider - } type Msg - = SliderMsg Slider.Msg - - --- INIT -init : (Model, Cmd Msg) -init = - (initialModel, Cmd.none) - - --- UPDATE -update : Msg -> Model -> (Model, Cmd Msg) -update msg model = - case msg of - SliderMsg sliderMsg -> - let - ( newSlider, cmd, updateResults ) = - Slider.update sliderMsg model.slider + = NoOp + | DoubleSliderLowChange Float + | DoubleSliderHighChange Float + - newModel = - { model | slider = newSlider } - - newCmd = - if updateResults then - Cmd.batch [ Cmd.map SliderMsg cmd, Cmd.none ] - else - Cmd.none - in - ( newModel, newCmd ) - - --- SUBSCRIPTIONS -subscriptions : Model -> Sub Msg -subscriptions model = - Sub.batch - [ Sub.map SliderMsg <| - Slider.subscriptions model.slider - ] - - --- VIEW view : Model -> Html Msg view model = - div - [] - [ Slider.view model.slider |> Html.map SliderMsg ] - + div [] + [ div [] [ DoubleSlider.view model.doubleSlider ] ] ``` +### Using a custom formatter -## CSS - -The is the base CSS for both single and double sliders. It is compatible with all major browsers including Internet Explorer 11. - -We recommend to start with the following styles and override them according to the theme of your website. - -Both sliders have a width set to 100% of the parent element. Therefore, in order to set a fixed width, we recommend to set it on the parent element and not override the width of the range slider. This is to ensure the flexibility of the component. - -```css -.input-range-container { - display: inline-flex; - align-items: center; - position: relative; - height: 48px; -} - -.input-range-container, -.input-range { - width: 100%; -} - -.input-range, -.input-range:hover, -.input-range:focus { - box-shadow: none; -} - -.input-range { - -webkit-appearance: none; - background-color: transparent; - padding: 0; - overflow: visible; - pointer-events: none; - height: 48px; - border: 0; -} - -.input-range::-moz-focus-outer { - border: 0; -} - -.input-range::-webkit-slider-thumb { - -webkit-appearance: none; - height: 32px; - width: 32px; - border: none; - background-color: white; - border-radius: 100%; - box-shadow: 0 0 0 2px rgba(33, 34, 36, 0.07); - cursor: pointer; - pointer-events: all; - z-index: 2; - position: relative; -} - -.input-range::-moz-range-track { - background: transparent; -} - -.input-range::-moz-range-thumb { - height: 32px; - width: 32px; - border: none; - background-color: white; - border-radius: 100%; - box-shadow: 0 0 0 2px rgba(33, 34, 36, 0.07); - cursor: pointer; - pointer-events: all; - z-index: 2; - position: relative; - transform: scale(1); -} - -.input-range::-ms-track { - background-color: transparent; - border-color: transparent; - color: transparent; -} - -.input-range::-ms-fill-lower { - background-color: transparent; -} - -.input-range::-ms-thumb { - height: 32px; - width: 32px; - border: none; - background-color: white; - border-radius: 100%; - box-shadow: 0 0 0 2px rgba(33, 34, 36, 0.07); - cursor: pointer; - pointer-events: all; - z-index: 2; - position: relative; -} +You can use a custom min label formatter, max label formatter, or value formatter like below: -.input-range:disabled, .input-range:disabled:hover { - cursor: not-allowed; - box-shadow: none; - border: 0; - background-color: transparent; -} - -.input-range:disabled::-webkit-slider-thumb, .input-range:disabled:hover::-webkit-slider-thumb { - cursor: not-allowed; -} - -.input-range:disabled::-moz-range-thumb, .input-range:disabled:hover::-moz-range-thumb { - cursor: not-allowed; -} - -.input-range:disabled::-ms-thumb, .input-range:disabled:hover::-ms-thumb { - cursor: not-allowed; -} - -.input-range:disabled ~ .input-range__track, .input-range:disabled:hover ~ .input-range__track { - cursor: not-allowed; - background-color: #fafafa; -} - -.input-range:disabled ~ .input-range__progress, .input-range:disabled:hover ~ .input-range__progress { - cursor: not-allowed; - background-color: #dcdee1; -} - -.slider-thumb { - height: 32px; - width: 32px; - border: none; - background-color: white; - border-radius: 100%; - box-shadow: 0 0 0 2px rgba(33, 34, 36, 0.07); - cursor: pointer; - pointer-events: all; - z-index: 2; - position: relative; - z-index: 2; -} - -.slider-thumb--first { - margin-left: -16px; -} - -.slider-thumb--second { - margin-left: -32px; -} - -.input-range--first { - position: absolute; -} - -.input-range--second { - position: relative; -} - -.input-range__track, -.input-range__progress { - border-radius: 8px; - position: absolute; - height: 8px; - margin-top: -4px; - top: 50%; - z-index: 0; -} - -.input-range__track:hover, -.input-range__progress:hover { - cursor: pointer; -} - -.input-range__track { - background-color: #dcdee1; - left: 0; - right: 0; -} - -.input-range__track:hover { - cursor: pointer; -} - -.input-range__progress { - background-color: #00a4ff; -} - -.input-range-labels-container { - display: flex; - justify-content: space-between; -} - -.input-range-label { - font-weight: bold; -} +```elm +singleSlider = + SingleSlider.init + { min = 0 + , max = 1000 + , value = 500 + , step = 50 + , onChange = handleSingleSliderChange + } + |> SingleSlider.withMinFormatter minFormatter +``` -.input-range-label--current-value { - color: #00a4ff; - text-align: center; - flex: 2; -} +## CSS -.input-range-label:first-child { - text-align: left; -} +Example CSS for the slider components is provided at `examples/example.css` -.input-range-label:last-child { - text-align: right; -} -``` +It is recommended to start with these styles and override them according to the theme of your website. diff --git a/examples/Main.elm b/examples/Main.elm new file mode 100644 index 0000000..fa7e61f --- /dev/null +++ b/examples/Main.elm @@ -0,0 +1,114 @@ +module Main exposing (main) + +import Browser +import DoubleSlider as DoubleSlider exposing (..) +import Html exposing (Html, button, div, text) +import Html.Events exposing (onClick) +import RangeSlider as RangeSlider exposing (..) +import SingleSlider exposing (..) + + +main : Program Flags Model Msg +main = + Browser.element { init = init, update = update, view = view, subscriptions = subscriptions } + + + +-- MODEL + + +type alias Model = + { singleSlider : SingleSlider.SingleSlider Msg + , doubleSlider : DoubleSlider.DoubleSlider Msg + } + + +type alias Flags = + {} + + +init : Flags -> ( Model, Cmd Msg ) +init flags = + let + minFormatter = + \value -> String.fromFloat value + + model = + { singleSlider = + SingleSlider.init + { min = 0 + , max = 1000 + , value = 500 + , step = 50 + , onChange = SingleSliderChange + } + |> SingleSlider.withMinFormatter minFormatter + , doubleSlider = + DoubleSlider.init + { min = 0 + , max = 1000 + , lowValue = 500 + , highValue = 750 + , step = 50 + , onLowChange = DoubleSliderLowChange + , onHighChange = DoubleSliderHighChange + } + } + in + ( model, Cmd.none ) + + + +-- UPDATE + + +type Msg + = NoOp + | DoubleSliderLowChange Float + | DoubleSliderHighChange Float + | SingleSliderChange Float + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + NoOp -> + ( model, Cmd.none ) + + DoubleSliderLowChange str -> + let + newSlider = + DoubleSlider.updateLowValue str model.doubleSlider + in + ( { model | doubleSlider = newSlider }, Cmd.none ) + + DoubleSliderHighChange str -> + let + newSlider = + DoubleSlider.updateHighValue str model.doubleSlider + in + ( { model | doubleSlider = newSlider }, Cmd.none ) + + SingleSliderChange str -> + let + newSlider = + SingleSlider.update str model.singleSlider + in + ( { model | singleSlider = newSlider }, Cmd.none ) + + + +-- VIEW + + +view : Model -> Html Msg +view model = + div [] + [ div [] [ DoubleSlider.view model.doubleSlider ] + , div [] [ SingleSlider.view model.singleSlider ] + ] + + +subscriptions : Model -> Sub msg +subscriptions model = + Sub.none diff --git a/examples/example.css b/examples/example.css new file mode 100644 index 0000000..9f0c269 --- /dev/null +++ b/examples/example.css @@ -0,0 +1,198 @@ +.input-range-container { + display: inline-flex; + align-items: center; + position: relative; + height: 48px; +} + +/* In the case of the double slider, each individual slider has it's width set to 100% of the parent element. Therefore, in order to set a fixed width, it is recommended to set it on the parent element and not override the width of the range slider. This is to ensure the flexibility of the component. */ +.input-range-container, +.input-range { + width: 100%; +} + +.input-range, +.input-range:hover, +.input-range:focus { + box-shadow: none; +} + +.input-range { + -webkit-appearance: none; + background-color: transparent; + padding: 0; + overflow: visible; + pointer-events: none; + height: 48px; + border: 0; +} + +.input-range::-moz-focus-outer { + border: 0; +} + +.input-range::-webkit-slider-thumb { + -webkit-appearance: none; + height: 32px; + width: 32px; + border: none; + background-color: white; + border-radius: 100%; + box-shadow: 0 0 0 2px rgba(33, 34, 36, 0.07); + cursor: pointer; + pointer-events: all; + z-index: 2; + position: relative; +} + +.input-range::-moz-range-track { + background: transparent; +} + +.input-range::-moz-range-thumb { + height: 32px; + width: 32px; + border: none; + background-color: white; + border-radius: 100%; + box-shadow: 0 0 0 2px rgba(33, 34, 36, 0.07); + cursor: pointer; + pointer-events: all; + z-index: 2; + position: relative; + transform: scale(1); +} + +.input-range::-ms-track { + background-color: transparent; + border-color: transparent; + color: transparent; +} + +.input-range::-ms-fill-lower { + background-color: transparent; +} + +.input-range::-ms-thumb { + height: 32px; + width: 32px; + border: none; + background-color: white; + border-radius: 100%; + box-shadow: 0 0 0 2px rgba(33, 34, 36, 0.07); + cursor: pointer; + pointer-events: all; + z-index: 2; + position: relative; +} + +.input-range:disabled, .input-range:disabled:hover { + cursor: not-allowed; + box-shadow: none; + border: 0; + background-color: transparent; +} + +.input-range:disabled::-webkit-slider-thumb, .input-range:disabled:hover::-webkit-slider-thumb { + cursor: not-allowed; +} + +.input-range:disabled::-moz-range-thumb, .input-range:disabled:hover::-moz-range-thumb { + cursor: not-allowed; +} + +.input-range:disabled::-ms-thumb, .input-range:disabled:hover::-ms-thumb { + cursor: not-allowed; +} + +.input-range:disabled ~ .input-range__track, .input-range:disabled:hover ~ .input-range__track { + cursor: not-allowed; + background-color: #fafafa; +} + +.input-range:disabled ~ .input-range__progress, .input-range:disabled:hover ~ .input-range__progress { + cursor: not-allowed; + background-color: #dcdee1; +} + +.slider-thumb { + height: 32px; + width: 32px; + border: none; + background-color: white; + border-radius: 100%; + box-shadow: 0 0 0 2px rgba(33, 34, 36, 0.07); + cursor: pointer; + pointer-events: all; + z-index: 2; + position: relative; + z-index: 2; +} + +.slider-thumb--first { + margin-left: -16px; +} + +.slider-thumb--second { + margin-left: -32px; +} + +.input-range--first { + position: absolute; +} + +.input-range--second { + position: relative; +} + +.input-range__track, +.input-range__progress { + border-radius: 8px; + position: absolute; + height: 8px; + margin-top: -4px; + top: 50%; + z-index: 0; +} + +.input-range__track:hover, +.input-range__progress:hover { + cursor: pointer; +} + +.input-range__track { + background-color: #dcdee1; + left: 0; + right: 0; +} + +.input-range__track:hover { + cursor: pointer; +} + +.input-range__progress { + background-color: #00a4ff; +} + +.input-range-labels-container { + display: flex; + justify-content: space-between; +} + +.input-range-label { + font-weight: bold; +} + +.input-range-label--current-value { + color: #00a4ff; + text-align: center; + flex: 2; +} + +.input-range-label:first-child { + text-align: left; +} + +.input-range-label:last-child { + text-align: right; +} diff --git a/src/DoubleSlider.elm b/src/DoubleSlider.elm index c8d37db..23a8294 100644 --- a/src/DoubleSlider.elm +++ b/src/DoubleSlider.elm @@ -1,156 +1,59 @@ -module DoubleSlider exposing - ( Model, defaultModel - , Msg, update, subscriptions - , view, fallbackView, formatCurrentRange - ) +module DoubleSlider exposing (DoubleSlider, init, updateHighValue, updateLowValue, view) -{-| A single slider built natively in Elm - - -# Model - -@docs Model, defaultModel - - -# Update - -@docs Msg, update, subscriptions - - -# View +import DOM exposing (boundingClientRect) +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events +import Json.Decode +import RangeSlider -@docs view, fallbackView, formatCurrentRange --} +type DoubleSlider msg + = DoubleSlider + { commonAttributes : RangeSlider.CommonAttributes + , lowValueAttributes : RangeSlider.ValueAttributes msg + , highValueAttributes : RangeSlider.ValueAttributes msg + , currentRangeFormatter : { lowValue : Float, highValue : Float, min : Float, max : Float } -> String + , overlapThreshold : Float + } -import Browser -import DOM exposing (boundingClientRect) -import Html exposing (Html, div, input) -import Html.Attributes exposing (..) -import Html.Events exposing (on, targetValue) -import Json.Decode exposing (map) +type Thumb + = High + | Low -{-| The base model for the slider --} -type alias Model = - { min : Float - , max : Float - , step : Int - , lowValue : Float - , highValue : Float - , overlapThreshold : Float - , minFormatter : Float -> String - , maxFormatter : Float -> String - , currentRangeFormatter : Float -> Float -> Float -> Float -> String - } +changeMsg : DoubleSlider msg -> Thumb -> (Float -> msg) +changeMsg (DoubleSlider slider) thumb = + case thumb of + Low -> + slider.lowValueAttributes.change -type SliderValueType - = LowValue - | HighValue - | None - - -{-| The basic type accepted by the update --} -type Msg - = TrackClicked SliderValueType String - | RangeChanged SliderValueType String Bool - - -{-| Returns a default range slider --} -defaultModel : Model -defaultModel = - { min = 0 - , max = 100 - , step = 10 - , lowValue = 0 - , highValue = 100 - , overlapThreshold = 1 - , minFormatter = String.fromFloat - , maxFormatter = String.fromFloat - , currentRangeFormatter = defaultCurrentRangeFormatter - } + High -> + slider.highValueAttributes.change -defaultCurrentRangeFormatter : Float -> Float -> Float -> Float -> String -defaultCurrentRangeFormatter lowValue highValue min max = - String.join " " [ String.fromFloat lowValue, "-", String.fromFloat highValue ] - - -{-| takes a model and a message and applies it to create an updated model --} -update : Msg -> Model -> ( Model, Cmd Msg, Bool ) -update message model = - case message of - RangeChanged valueType newValue shouldFetchModels -> - let - convertedValue = - String.toFloat newValue |> Maybe.withDefault 0 - - newModel = - case valueType of - LowValue -> - let - newLowValue = - Basics.min convertedValue (model.highValue - (toFloat model.step * model.overlapThreshold)) - in - { model | lowValue = newLowValue } - - HighValue -> - let - newHighValue = - Basics.max convertedValue (model.lowValue + (toFloat model.step * model.overlapThreshold)) - in - { model | highValue = newHighValue } - - None -> - model - in - ( newModel, Cmd.none, shouldFetchModels ) - - TrackClicked valueType newValue -> - let - convertedValue = - snapValue (String.toFloat newValue |> Maybe.withDefault 0) model.step - - newModel = - case valueType of - LowValue -> - { model | lowValue = convertedValue } - - HighValue -> - { model | highValue = convertedValue } - - None -> - model - in - ( newModel, Cmd.none, True ) - - -snapValue : Float -> Int -> Float +snapValue : Float -> Float -> Float snapValue value step = - toFloat ((round value // step) * step) + (value / step) * step -onOutsideRangeClick : Model -> Json.Decode.Decoder Msg -onOutsideRangeClick model = +onOutsideRangeClick : DoubleSlider msg -> Json.Decode.Decoder msg +onOutsideRangeClick (DoubleSlider ({ commonAttributes, lowValueAttributes, highValueAttributes } as slider)) = let valueTypeDecoder = Json.Decode.map2 (\rectangle mouseX -> let newValue = - snapValue ((model.max / rectangle.width) * mouseX) model.step + snapValue ((commonAttributes.max / rectangle.width) * mouseX) commonAttributes.step valueType = - if newValue < model.lowValue then - LowValue + if newValue < lowValueAttributes.value then + Low else - HighValue + High in valueType ) @@ -162,18 +65,18 @@ onOutsideRangeClick model = (\rectangle mouseX -> let newValue = - (((model.max - model.min) / rectangle.width) * mouseX) + model.min + (((commonAttributes.max - commonAttributes.min) / rectangle.width) * mouseX) + commonAttributes.min in - String.fromInt (round newValue) + newValue ) (Json.Decode.at [ "target" ] boundingClientRect) (Json.Decode.at [ "offsetX" ] Json.Decode.float) in - Json.Decode.map2 TrackClicked valueTypeDecoder valueDecoder + Json.Decode.map2 (changeMsg (DoubleSlider slider)) valueTypeDecoder valueDecoder -onInsideRangeClick : Model -> Json.Decode.Decoder Msg -onInsideRangeClick model = +onInsideRangeClick : DoubleSlider msg -> Json.Decode.Decoder msg +onInsideRangeClick (DoubleSlider ({ commonAttributes, lowValueAttributes, highValueAttributes } as slider)) = let valueTypeDecoder = Json.Decode.map2 @@ -184,10 +87,10 @@ onInsideRangeClick model = valueType = if mouseX < centerThreshold then - LowValue + Low else - HighValue + High in valueType ) @@ -199,114 +102,228 @@ onInsideRangeClick model = (\rectangle mouseX -> let newValue = - snapValue ((((model.highValue - model.lowValue) / rectangle.width) * mouseX) + model.lowValue) model.step + snapValue ((((highValueAttributes.value - lowValueAttributes.value) / rectangle.width) * mouseX) + lowValueAttributes.value) commonAttributes.step in - String.fromInt (round newValue) + newValue ) (Json.Decode.at [ "target" ] boundingClientRect) (Json.Decode.at [ "offsetX" ] Json.Decode.float) in - Json.Decode.map2 TrackClicked valueTypeDecoder valueDecoder + Json.Decode.map2 (changeMsg (DoubleSlider slider)) valueTypeDecoder valueDecoder -onRangeChange : SliderValueType -> Bool -> Json.Decode.Decoder Msg -onRangeChange valueType shouldFetchModels = - Json.Decode.map3 - RangeChanged - (Json.Decode.succeed valueType) - targetValue - (Json.Decode.succeed shouldFetchModels) +formatCurrentRange : DoubleSlider msg -> String +formatCurrentRange (DoubleSlider slider) = + slider.currentRangeFormatter + { lowValue = slider.lowValueAttributes.value + , highValue = slider.highValueAttributes.value + , min = slider.commonAttributes.min + , max = slider.commonAttributes.max + } -{-| Displays the slider --} -view : Model -> Html Msg -view model = +progressView : DoubleSlider msg -> Html msg +progressView (DoubleSlider ({ commonAttributes, lowValueAttributes, highValueAttributes } as slider)) = let lowValue = - round model.lowValue + lowValueAttributes.value highValue = - round model.highValue + highValueAttributes.value progressRatio = - 100 / (model.max - model.min) + 100 / (commonAttributes.max - commonAttributes.min) progressLow = - String.fromFloat ((model.lowValue - model.min) * progressRatio) ++ "%" + String.fromFloat ((lowValue - commonAttributes.min) * progressRatio) ++ "%" progressHigh = - String.fromFloat ((model.max - model.highValue) * progressRatio) ++ "%" + String.fromFloat ((commonAttributes.max - highValue) * progressRatio) ++ "%" in - div [] - [ div - [ Html.Attributes.class "input-range-container" ] - [ Html.input - [ Html.Attributes.type_ "range" - , Html.Attributes.min (String.fromFloat model.min) - , Html.Attributes.max (String.fromFloat model.max) - , Html.Attributes.value <| String.fromFloat model.lowValue - , Html.Attributes.step (String.fromInt model.step) - , Html.Attributes.class "input-range input-range--first" - , Html.Events.on "change" (onRangeChange LowValue True) - , Html.Events.on "input" (onRangeChange LowValue False) - ] - [] - , Html.input - [ Html.Attributes.type_ "range" - , Html.Attributes.min (String.fromFloat model.min) - , Html.Attributes.max (String.fromFloat model.max) - , Html.Attributes.value <| String.fromFloat model.highValue - , Html.Attributes.step (String.fromInt model.step) - , Html.Attributes.class "input-range input-range--second" - , Html.Events.on "change" (onRangeChange HighValue True) - , Html.Events.on "input" (onRangeChange HighValue False) - ] - [] - , div - [ Html.Attributes.class "input-range__track" - , Html.Events.on "click" (onOutsideRangeClick model) - ] - [] - , div - [ Html.Attributes.class "input-range__progress" - , Html.Attributes.style "left" progressLow - , Html.Attributes.style "right" progressHigh - , Html.Events.on "click" (onInsideRangeClick model) - ] - [] - ] - , div - [ Html.Attributes.class "input-range-labels-container" ] - [ div [ Html.Attributes.class "input-range-label" ] [ Html.text (model.minFormatter model.min) ] - , div - [ Html.Attributes.class "input-range-label input-range-label--current-value" ] - [ Html.text (formatCurrentRange model) ] - , div [ Html.Attributes.class "input-range-label" ] [ Html.text (model.maxFormatter model.max) ] - ] + div + [ Html.Attributes.class "input-range__progress" + , Html.Attributes.style "left" progressLow + , Html.Attributes.style "right" progressHigh + , Html.Events.on "click" (onInsideRangeClick (DoubleSlider slider)) ] + [] + + +inputDecoder : DoubleSlider msg -> Thumb -> Json.Decode.Decoder Float +inputDecoder (DoubleSlider slider) thumb = + Json.Decode.map (\value -> String.toFloat value |> Maybe.withDefault 0 |> convertValue (DoubleSlider slider) thumb) + Html.Events.targetValue + +convertValue : DoubleSlider msg -> Thumb -> Float -> Float +convertValue (DoubleSlider slider) thumb value = + case thumb of + Low -> + Basics.min value (slider.highValueAttributes.value - (slider.commonAttributes.step * slider.overlapThreshold)) -{-| DEPRECATED: Displays the slider --} -fallbackView : Model -> Html Msg -fallbackView model = - view model + High -> + Basics.max value (slider.lowValueAttributes.value + (slider.commonAttributes.step * slider.overlapThreshold)) -{-| Renders the current values using the formatter --} -formatCurrentRange : Model -> String -formatCurrentRange model = - if model.lowValue == model.min && model.highValue == model.max then +defaultCurrentRangeFormatter : { lowValue : Float, highValue : Float, min : Float, max : Float } -> String +defaultCurrentRangeFormatter values = + if values.lowValue == values.min && values.highValue == values.max then "" else - model.currentRangeFormatter model.lowValue model.highValue model.min model.max + String.join " " [ String.fromFloat values.lowValue, "-", String.fromFloat values.highValue ] -{-| Returns the subscriptions necessary to run --} -subscriptions : Model -> Sub Msg -subscriptions model = - Sub.none + +-- API + + +init : + { min : Float + , max : Float + , step : Float + , lowValue : Float + , highValue : Float + , onLowChange : Float -> msg + , onHighChange : Float -> msg + } + -> DoubleSlider msg +init attrs = + DoubleSlider + { commonAttributes = + { min = attrs.min + , max = attrs.max + , step = attrs.step + , minFormatter = RangeSlider.defaultLabelFormatter + , maxFormatter = RangeSlider.defaultLabelFormatter + } + , lowValueAttributes = + { value = attrs.lowValue + , change = attrs.onLowChange + , formatter = RangeSlider.defaultValueFormatter + } + , highValueAttributes = + { value = attrs.highValue + , change = attrs.onHighChange + , formatter = RangeSlider.defaultValueFormatter + } + , currentRangeFormatter = defaultCurrentRangeFormatter + , overlapThreshold = 1.0 + } + + +withMinFormatter : (Float -> String) -> DoubleSlider msg -> DoubleSlider msg +withMinFormatter formatter (DoubleSlider ({ commonAttributes } as slider)) = + DoubleSlider + { lowValueAttributes = slider.lowValueAttributes + , highValueAttributes = slider.highValueAttributes + , currentRangeFormatter = slider.currentRangeFormatter + , overlapThreshold = slider.overlapThreshold + , commonAttributes = { commonAttributes | minFormatter = formatter } + } + + +withMaxFormatter : (Float -> String) -> DoubleSlider msg -> DoubleSlider msg +withMaxFormatter formatter (DoubleSlider ({ commonAttributes } as slider)) = + DoubleSlider + { lowValueAttributes = slider.lowValueAttributes + , highValueAttributes = slider.highValueAttributes + , currentRangeFormatter = slider.currentRangeFormatter + , overlapThreshold = slider.overlapThreshold + , commonAttributes = { commonAttributes | maxFormatter = formatter } + } + + +withLowValueFormatter : (Float -> Float -> String) -> DoubleSlider msg -> DoubleSlider msg +withLowValueFormatter formatter (DoubleSlider ({ lowValueAttributes } as slider)) = + DoubleSlider + { lowValueAttributes = { lowValueAttributes | formatter = formatter } + , commonAttributes = slider.commonAttributes + , highValueAttributes = slider.highValueAttributes + , currentRangeFormatter = slider.currentRangeFormatter + , overlapThreshold = slider.overlapThreshold + } + + +withHighValueFormatter : (Float -> Float -> String) -> DoubleSlider msg -> DoubleSlider msg +withHighValueFormatter formatter (DoubleSlider ({ highValueAttributes } as slider)) = + DoubleSlider + { highValueAttributes = { highValueAttributes | formatter = formatter } + , commonAttributes = slider.commonAttributes + , lowValueAttributes = slider.lowValueAttributes + , currentRangeFormatter = slider.currentRangeFormatter + , overlapThreshold = slider.overlapThreshold + } + + +withOverlapThreshold : Float -> DoubleSlider msg -> DoubleSlider msg +withOverlapThreshold overlapThreshold (DoubleSlider slider) = + DoubleSlider + { highValueAttributes = slider.highValueAttributes + , commonAttributes = slider.commonAttributes + , lowValueAttributes = slider.lowValueAttributes + , currentRangeFormatter = slider.currentRangeFormatter + , overlapThreshold = overlapThreshold + } + + +withCurrentRangeFormatter : ({ lowValue : Float, highValue : Float, min : Float, max : Float } -> String) -> DoubleSlider msg -> DoubleSlider msg +withCurrentRangeFormatter currentRangeFormatter (DoubleSlider slider) = + DoubleSlider + { highValueAttributes = slider.highValueAttributes + , commonAttributes = slider.commonAttributes + , lowValueAttributes = slider.lowValueAttributes + , currentRangeFormatter = currentRangeFormatter + , overlapThreshold = slider.overlapThreshold + } + + +updateLowValue : Float -> DoubleSlider msg -> DoubleSlider msg +updateLowValue value (DoubleSlider ({ lowValueAttributes, highValueAttributes, commonAttributes } as slider)) = + DoubleSlider + { commonAttributes = slider.commonAttributes + , lowValueAttributes = + { lowValueAttributes + | value = Basics.min value (slider.highValueAttributes.value - commonAttributes.step) + } + , highValueAttributes = highValueAttributes + , currentRangeFormatter = slider.currentRangeFormatter + , overlapThreshold = slider.overlapThreshold + } + + +updateHighValue : Float -> DoubleSlider msg -> DoubleSlider msg +updateHighValue value (DoubleSlider ({ lowValueAttributes, highValueAttributes, commonAttributes } as slider)) = + DoubleSlider + { commonAttributes = commonAttributes + , lowValueAttributes = lowValueAttributes + , highValueAttributes = + { highValueAttributes + | value = Basics.max value (lowValueAttributes.value - commonAttributes.step) + } + , currentRangeFormatter = slider.currentRangeFormatter + , overlapThreshold = slider.overlapThreshold + } + + +view : DoubleSlider msg -> Html msg +view (DoubleSlider slider) = + div [] + [ div [ Html.Attributes.class "input-range-container" ] + [ RangeSlider.sliderInputView slider.commonAttributes slider.lowValueAttributes (inputDecoder (DoubleSlider slider) Low) + , RangeSlider.sliderInputView slider.commonAttributes slider.highValueAttributes (inputDecoder (DoubleSlider slider) High) + , RangeSlider.sliderTrackView (onOutsideRangeClick (DoubleSlider slider)) + , progressView (DoubleSlider slider) + ] + , div [ Html.Attributes.class "input-range-labels-container" ] + [ div + [ Html.Attributes.class "input-range-label" ] + [ Html.text (slider.commonAttributes.minFormatter slider.commonAttributes.min) ] + , div + [ Html.Attributes.class "input-range-label input-range-label--current-value" ] + [ Html.text (formatCurrentRange (DoubleSlider slider)) ] + , div + [ Html.Attributes.class "input-range-label" ] + [ Html.text (slider.commonAttributes.maxFormatter slider.commonAttributes.max) ] + ] + ] diff --git a/src/RangeSlider.elm b/src/RangeSlider.elm new file mode 100644 index 0000000..2520d22 --- /dev/null +++ b/src/RangeSlider.elm @@ -0,0 +1,65 @@ +module RangeSlider exposing (CommonAttributes, ValueAttributes, defaultLabelFormatter, defaultValueFormatter, onClick, sliderInputView, sliderTrackView) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Json.Decode exposing (..) + + +type alias ValueAttributes msg = + { change : Float -> msg + , value : Float + , formatter : Float -> Float -> String + } + + +type alias CommonAttributes = + { max : Float + , min : Float + , step : Float + , minFormatter : Float -> String + , maxFormatter : Float -> String + } + + +onChange : (Float -> msg) -> Json.Decode.Decoder Float -> Html.Attribute msg +onChange msg input = + Html.Events.on "change" (Json.Decode.map msg input) + + +sliderInputView : CommonAttributes -> ValueAttributes msg -> Json.Decode.Decoder Float -> Html msg +sliderInputView commonAttributes valueAttributes input = + Html.input + [ Html.Attributes.type_ "range" + , Html.Attributes.min <| String.fromFloat commonAttributes.min + , Html.Attributes.max <| String.fromFloat commonAttributes.max + , Html.Attributes.step <| String.fromFloat commonAttributes.step + , Html.Attributes.value <| String.fromFloat valueAttributes.value + , Html.Attributes.class "input-range" + , onChange valueAttributes.change input + ] + [] + + +sliderTrackView : Json.Decode.Decoder msg -> Html msg +sliderTrackView decoder = + div [ Html.Attributes.class "input-range__track", onClick decoder ] [] + + +onClick : Json.Decode.Decoder msg -> Html.Attribute msg +onClick decoder = + Html.Events.on "click" decoder + + +defaultLabelFormatter : Float -> String +defaultLabelFormatter value = + String.fromFloat value + + +defaultValueFormatter : Float -> Float -> String +defaultValueFormatter value max = + if value == max then + "" + + else + String.fromFloat value diff --git a/src/SingleSlider.elm b/src/SingleSlider.elm index 5e0aeba..0a89771 100644 --- a/src/SingleSlider.elm +++ b/src/SingleSlider.elm @@ -1,125 +1,18 @@ -module SingleSlider exposing - ( Model, defaultModel, ProgressDirection(..) - , Msg(..), update, subscriptions - , view - ) - -{-| A single slider built natively in Elm - - -# Model - -@docs Model, defaultModel, ProgressDirection - - -# Update - -@docs Msg, update, subscriptions - - -# View - -@docs view - --} +module SingleSlider exposing (SingleSlider, init, update, view, withMaxFormatter, withMinFormatter, withValueFormatter) import DOM exposing (boundingClientRect) -import Html exposing (Html, div, input) +import Html exposing (..) import Html.Attributes exposing (..) -import Html.Events exposing (on, targetValue) -import Json.Decode exposing (map) +import Html.Events exposing (..) +import Json.Decode +import RangeSlider -{-| The base model for the slider --} -type alias Model = - { min : Float - , max : Float - , step : Float - , value : Float - , minFormatter : Float -> String - , maxFormatter : Float -> String - , currentValueFormatter : Float -> Float -> String - , disabled : Bool - , progressDirection : ProgressDirection - , reversed : Bool - } - - -{-| The basic type accepted by the update --} -type Msg - = TrackClicked String - | OnInput String Bool - | OnChange String - - -{-| Progress Bar direction (left or right) --} -type ProgressDirection - = ProgressLeft - | ProgressRight - - -{-| Default model --} -defaultModel : Model -defaultModel = - { min = 0 - , max = 100 - , step = 10 - , value = 0 - , minFormatter = String.fromFloat - , maxFormatter = String.fromFloat - , currentValueFormatter = defaultCurrentValueFormatter - , disabled = False - , progressDirection = ProgressLeft - , reversed = False - } - - -{-| Default formatter for the current value --} -defaultCurrentValueFormatter : Float -> Float -> String -defaultCurrentValueFormatter currentValue max = - if currentValue == max then - "" - - else - String.fromFloat currentValue - - -{-| takes a model and a message and applies it to create an updated model --} -update : Msg -> Model -> ( Model, Cmd Msg, Bool ) -update message model = - case message of - OnInput newValue shouldFetchModels -> - let - convertedValue = - String.toFloat newValue |> Maybe.withDefault 0 - - newModel = - { model | value = convertedValue } - in - ( newModel, Cmd.none, shouldFetchModels ) - - OnChange newValue -> - let - convertedValue = - String.toFloat newValue |> Maybe.withDefault 0 - in - ( { model | value = convertedValue }, Cmd.none, True ) - - TrackClicked newValue -> - let - convertedValue = - snapValue (String.toFloat newValue |> Maybe.withDefault model.min) model - - newModel = - { model | value = convertedValue } - in - ( newModel, Cmd.none, True ) +type SingleSlider msg + = SingleSlider + { commonAttributes : RangeSlider.CommonAttributes + , valueAttributes : RangeSlider.ValueAttributes msg + } closestStep : Float -> Float -> Int @@ -145,11 +38,11 @@ closestStep value step = roundedValue - remainder -snapValue : Float -> Model -> Float -snapValue value model = +snapValue : Float -> SingleSlider msg -> Float +snapValue value (SingleSlider slider) = let roundedStep = - round model.step + round slider.commonAttributes.step adjustedRoundedStep = if roundedStep > 0 then @@ -162,12 +55,7 @@ snapValue value model = value / toFloat adjustedRoundedStep roundedValue = - case model.progressDirection of - ProgressLeft -> - floor newValue - - ProgressRight -> - ceiling newValue + floor newValue nextValue = toFloat (roundedValue * adjustedRoundedStep) @@ -175,185 +63,158 @@ snapValue value model = nextValue -onOutsideRangeClick : Model -> Json.Decode.Decoder Msg -onOutsideRangeClick model = +onOutsideRangeClick : SingleSlider msg -> Json.Decode.Decoder msg +onOutsideRangeClick (SingleSlider ({ commonAttributes, valueAttributes } as slider)) = let valueDecoder = Json.Decode.map2 (\rectangle mouseX -> let clickedValue = - if model.reversed then - model.max - (((model.max - model.min) / rectangle.width) * mouseX) - - else - (((model.max - model.min) / rectangle.width) * mouseX) + model.min + (((commonAttributes.max - commonAttributes.min) / rectangle.width) * mouseX) + commonAttributes.min newValue = - closestStep clickedValue model.step + closestStep clickedValue commonAttributes.step in - String.fromInt newValue + toFloat newValue ) (Json.Decode.at [ "target" ] boundingClientRect) (Json.Decode.at [ "offsetX" ] Json.Decode.float) in - Json.Decode.map TrackClicked valueDecoder + Json.Decode.map slider.valueAttributes.change valueDecoder -onInsideRangeClick : Model -> Json.Decode.Decoder Msg -onInsideRangeClick model = +onInsideRangeClick : SingleSlider msg -> Json.Decode.Decoder msg +onInsideRangeClick (SingleSlider ({ commonAttributes, valueAttributes } as slider)) = let valueDecoder = Json.Decode.map2 (\rectangle mouseX -> let adjustedValue = - clamp model.min model.max model.value + clamp commonAttributes.min commonAttributes.max valueAttributes.value newValue = round <| - case model.progressDirection of - ProgressLeft -> - (adjustedValue / rectangle.width) * mouseX - - ProgressRight -> - if model.reversed then - adjustedValue - ((mouseX / rectangle.width) * (adjustedValue - model.min)) - - else - adjustedValue + ((mouseX / rectangle.width) * (model.max - adjustedValue)) + adjustedValue + - ((mouseX / rectangle.width) * (adjustedValue - commonAttributes.min)) adjustedNewValue = - clamp model.min model.max <| toFloat newValue + clamp commonAttributes.min commonAttributes.max <| toFloat newValue in - String.fromFloat adjustedNewValue + adjustedNewValue ) (Json.Decode.at [ "target" ] boundingClientRect) (Json.Decode.at [ "offsetX" ] Json.Decode.float) in - Json.Decode.map TrackClicked valueDecoder - - -onInput : Bool -> Json.Decode.Decoder Msg -onInput shouldFetchModels = - Json.Decode.map2 OnInput - targetValue - (Json.Decode.succeed shouldFetchModels) + Json.Decode.map valueAttributes.change valueDecoder -onChange : Json.Decode.Decoder Msg -onChange = - Json.Decode.map OnChange - targetValue - - -{-| Displays the slider --} -view : Model -> Html Msg -view model = +progressView : SingleSlider msg -> Html msg +progressView (SingleSlider ({ commonAttributes, valueAttributes } as slider)) = let - trackAttributes = - [ Html.Attributes.class "input-range__track" ] - - trackAllAttributes = - case model.disabled of - False -> - List.append trackAttributes [ Html.Events.on "click" (onOutsideRangeClick model) ] + progressRatio = + 100 / (commonAttributes.max - commonAttributes.min) - True -> - trackAttributes + value = + clamp commonAttributes.min commonAttributes.max valueAttributes.value - progressPercentages = - calculateProgressPercentages model + progress = + commonAttributes.max - value * progressRatio progressAttributes = [ Html.Attributes.class "input-range__progress" - , Html.Attributes.style "left" <| String.fromFloat progressPercentages.left ++ "%" - , Html.Attributes.style "right" <| String.fromFloat progressPercentages.right ++ "%" + , Html.Attributes.style "left" <| String.fromFloat 0.0 ++ "%" + , Html.Attributes.style "right" <| String.fromFloat progressRatio ++ "%" + , RangeSlider.onClick (onInsideRangeClick (SingleSlider slider)) ] + in + div progressAttributes [] - progressAllAttributes = - case model.disabled of - False -> - List.append progressAttributes [ Html.Events.on "click" (onInsideRangeClick model) ] - True -> - progressAttributes +inputDecoder : Json.Decode.Decoder Float +inputDecoder = + Json.Decode.map (\value -> String.toFloat value |> Maybe.withDefault 0) + Html.Events.targetValue - ( leftText, rightText ) = - if model.reversed then - ( model.maxFormatter model.max, model.minFormatter model.min ) - else - ( model.minFormatter model.min, model.maxFormatter model.max ) - in + +-- API + + +init : + { min : Float + , max : Float + , step : Float + , value : Float + , onChange : Float -> msg + } + -> SingleSlider msg +init attrs = + SingleSlider + { commonAttributes = + { min = attrs.min + , max = attrs.max + , step = attrs.step + , minFormatter = RangeSlider.defaultLabelFormatter + , maxFormatter = RangeSlider.defaultLabelFormatter + } + , valueAttributes = + { value = attrs.value + , change = attrs.onChange + , formatter = RangeSlider.defaultValueFormatter + } + } + + +withMinFormatter : (Float -> String) -> SingleSlider msg -> SingleSlider msg +withMinFormatter formatter (SingleSlider ({ commonAttributes } as slider)) = + SingleSlider + { valueAttributes = slider.valueAttributes + , commonAttributes = { commonAttributes | minFormatter = formatter } + } + + +withMaxFormatter : (Float -> String) -> SingleSlider msg -> SingleSlider msg +withMaxFormatter formatter (SingleSlider ({ commonAttributes } as slider)) = + SingleSlider + { valueAttributes = slider.valueAttributes + , commonAttributes = { commonAttributes | maxFormatter = formatter } + } + + +withValueFormatter : (Float -> Float -> String) -> SingleSlider msg -> SingleSlider msg +withValueFormatter formatter (SingleSlider ({ valueAttributes } as slider)) = + SingleSlider + { valueAttributes = { valueAttributes | formatter = formatter } + , commonAttributes = slider.commonAttributes + } + + +update : Float -> SingleSlider msg -> SingleSlider msg +update value (SingleSlider ({ valueAttributes } as slider)) = + SingleSlider + { valueAttributes = { valueAttributes | value = value } + , commonAttributes = slider.commonAttributes + } + + +view : SingleSlider msg -> Html msg +view (SingleSlider slider) = div [] - [ div - [ Html.Attributes.class "input-range-container" ] - [ Html.input - [ Html.Attributes.type_ "range" - , Html.Attributes.min (String.fromFloat model.min) - , Html.Attributes.max (String.fromFloat model.max) - , Html.Attributes.value <| String.fromFloat model.value - , Html.Attributes.step (String.fromFloat model.step) - , Html.Attributes.class "input-range" - , Html.Attributes.disabled model.disabled - , Html.Events.on "change" onChange - , Html.Events.on "input" (onInput True) - , Html.Attributes.style "direction" <| - if model.reversed then - "rtl" - - else - "ltr" - ] - [] - , div - trackAllAttributes - [] - , div - progressAllAttributes - [] - ] + [ RangeSlider.sliderInputView slider.commonAttributes slider.valueAttributes inputDecoder + , RangeSlider.sliderTrackView (onOutsideRangeClick (SingleSlider slider)) + , progressView (SingleSlider slider) , div [ Html.Attributes.class "input-range-labels-container" ] - [ div [ Html.Attributes.class "input-range-label" ] [ Html.text leftText ] - , div [ Html.Attributes.class "input-range-label input-range-label--current-value" ] - [ Html.text (model.currentValueFormatter model.value model.max) ] - , div [ Html.Attributes.class "input-range-label" ] [ Html.text rightText ] + [ div + [ Html.Attributes.class "input-range-label" ] + [ Html.text <| slider.commonAttributes.minFormatter slider.commonAttributes.min ] + , div + [ Html.Attributes.class "input-range-label input-range-label--current-value" ] + [ Html.text <| slider.valueAttributes.formatter slider.valueAttributes.value slider.commonAttributes.max ] + , div + [ Html.Attributes.class "input-range-label" ] + [ Html.text <| slider.commonAttributes.maxFormatter slider.commonAttributes.max ] ] ] - - -{-| Returns the percentage adjusted min, max values for the range (actual min - actual max) --} -calculateProgressPercentages : Model -> { left : Float, right : Float } -calculateProgressPercentages model = - let - progressRatio = - 100 / (model.max - model.min) - - value = - clamp model.min model.max model.value - in - case model.progressDirection of - ProgressRight -> - if model.reversed then - { left = 100 - (value - model.min) * progressRatio, right = 0.0 } - - else - { left = (value - model.min) * progressRatio, right = 0.0 } - - ProgressLeft -> - { left = 0.0, right = (model.max - value) * progressRatio } - - - --- Subscriptions --------------------------------------------------------------- - - -{-| Returns the subscriptions necessary to run --} -subscriptions : Model -> Sub Msg -subscriptions model = - Sub.none