Merge pull request #249 from NoRedInk/lab/slidimations

Lab/slidimations
This commit is contained in:
Tessa 2019-04-12 11:10:09 -07:00 committed by GitHub
commit a8d16eec72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 848 additions and 2 deletions

View File

@ -45,7 +45,9 @@
"Nri.Ui.SegmentedControl.V6",
"Nri.Ui.Select.V5",
"Nri.Ui.Svg.V1",
"Nri.Ui.Slide.V1",
"Nri.Ui.SlideModal.V1",
"Nri.Ui.SlideModal.V2",
"Nri.Ui.Table.V3",
"Nri.Ui.Table.V4",
"Nri.Ui.Tabs.V3",

107
src/Nri/Ui/Slide/V1.elm Normal file
View File

@ -0,0 +1,107 @@
module Nri.Ui.Slide.V1 exposing
( AnimationDirection(..)
, withSlidingContents
, animateIn, animateOut
)
{-| Note: You'll almost certainly want to used keyed nodes if you're
using this module.
@docs AnimationDirection
@docs withSlidingContents
@docs animateIn, animateOut
-}
import Css
import Css.Animations
{-| Slide from right to left or from left to right.
-}
type AnimationDirection
= FromRTL
| FromLTR
translateXBy : Float
translateXBy =
700
slideDuration : Css.Style
slideDuration =
Css.animationDuration (Css.ms 700)
slideTimingFunction : Css.Style
slideTimingFunction =
Css.property "animation-timing-function" "ease-in-out"
{-| Add this class to the container whose descendents are sliding.
-}
withSlidingContents : Css.Style
withSlidingContents =
Css.batch
[ Css.position Css.relative
, Css.overflowX Css.hidden
]
{-| Add this style to the element you want to animate in.
-}
animateIn : AnimationDirection -> Css.Style
animateIn direction =
let
( start, end ) =
case direction of
FromRTL ->
( Css.px translateXBy, Css.zero )
FromLTR ->
( Css.px -translateXBy, Css.zero )
in
Css.batch
[ slideDuration
, slideTimingFunction
, Css.property "animation-delay" "-50ms"
, Css.animationName
(Css.Animations.keyframes
[ ( 0, [ Css.Animations.transform [ Css.translateX start ] ] )
, ( 100, [ Css.Animations.transform [ Css.translateX end ] ] )
]
)
]
{-| Add this style to the element you want to animate out.
Note: this will absolutely position the element.
You must add `withSlidingContents` to one of its ancestors.
-}
animateOut : AnimationDirection -> Css.Style
animateOut direction =
let
( start, end ) =
case direction of
FromRTL ->
( Css.zero, Css.px -translateXBy )
FromLTR ->
( Css.zero, Css.px translateXBy )
in
Css.batch
[ Css.position Css.absolute
, Css.transform (Css.translate2 end Css.zero)
, Css.property "animation-delay" "-50ms"
, Css.batch
[ slideDuration
, slideTimingFunction
, Css.animationName
(Css.Animations.keyframes
[ ( 0, [ Css.Animations.transform [ Css.translateX start ] ] )
, ( 100, [ Css.Animations.transform [ Css.translateX end ] ] )
]
)
]
]

View File

@ -0,0 +1,411 @@
module Nri.Ui.SlideModal.V2 exposing
( Config, Panel
, State, closed, open
, view
)
{-|
@docs Config, Panel
@docs State, closed, open
@docs view
-}
import Accessibility.Styled as Html exposing (..)
import Accessibility.Styled.Aria exposing (labelledBy)
import Accessibility.Styled.Role as Role
import Accessibility.Styled.Style
import Accessibility.Styled.Widget as Widget
import Color
import Css
import Css.Animations
import Css.Global
import Html.Styled
import Html.Styled.Attributes exposing (css)
import Html.Styled.Events exposing (onClick)
import Html.Styled.Keyed as Keyed
import Nri.Ui
import Nri.Ui.AssetPath exposing (Asset(..))
import Nri.Ui.Button.V8 as Button
import Nri.Ui.Colors.Extra
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.Fonts.V1 as Fonts
import Nri.Ui.Icon.V3 as Icon
import Nri.Ui.Slide.V1 as Slide exposing (AnimationDirection(..))
import Nri.Ui.Text.V2 as Text
{-| -}
type alias Config msg =
{ panels : List Panel
, height : Css.Vh
, parentMsg : State -> msg
}
{-| -}
type State
= State
{ currentPanelIndex : Maybe Int
, previousPanel : Maybe ( AnimationDirection, Panel )
}
{-| Create the open state for the modal (the first panel will show).
-}
open : State
open =
State
{ currentPanelIndex = Just 0
, previousPanel = Nothing
}
{-| Close the modal.
-}
closed : State
closed =
State
{ currentPanelIndex = Nothing
, previousPanel = Nothing
}
{-| View the modal (includes the modal backdrop).
-}
view : Config msg -> State -> Html msg
view config ((State { currentPanelIndex }) as state) =
case Maybe.andThen (summarize config.panels) currentPanelIndex of
Just summary ->
viewBackdrop
(viewModal config state summary)
Nothing ->
Html.text ""
type alias Summary =
{ current : Panel
, upcoming : List ( State, String )
, previous : List ( State, String )
}
summarize : List Panel -> Int -> Maybe Summary
summarize panels current =
let
indexedPanels =
List.indexedMap (\i { title } -> ( i, title )) panels
toOtherPanel direction currentPanel ( i, title ) =
( State
{ currentPanelIndex = Just i
, previousPanel =
Just
( direction
, { currentPanel | content = currentPanel.content }
)
}
, title
)
in
case List.drop current panels of
currentPanel :: rest ->
Just
{ current = currentPanel
, upcoming =
indexedPanels
|> List.drop (current + 1)
|> List.map (toOtherPanel FromRTL currentPanel)
, previous =
indexedPanels
|> List.take current
|> List.map (toOtherPanel FromLTR currentPanel)
}
[] ->
Nothing
viewModal : Config msg -> State -> Summary -> Html msg
viewModal config (State { previousPanel }) summary =
Keyed.node "div"
[ css
[ Css.boxSizing Css.borderBox
, Css.margin2 (Css.px 75) Css.auto
, Css.backgroundColor Colors.white
, Css.borderRadius (Css.px 20)
, Css.property "box-shadow" "0 1px 10px 0 rgba(0, 0, 0, 0.35)"
, Slide.withSlidingContents
]
, Role.dialog
, Widget.modal True
, labelledBy (panelId summary.current)
]
(case previousPanel of
Just ( direction, panelView ) ->
( panelId panelView
, panelContainer config.height
[ Slide.animateOut direction ]
[ viewIcon panelView.icon
, Text.subHeading
[ span [ Html.Styled.Attributes.id (panelId panelView) ] [ Html.text panelView.title ]
]
, viewContent panelView.content
]
)
:: viewModalContent config summary [ Slide.animateIn direction ]
Nothing ->
viewModalContent config summary []
)
viewModalContent : Config msg -> Summary -> List Css.Style -> List ( String, Html msg )
viewModalContent config summary styles =
[ ( panelId summary.current
, panelContainer config.height
styles
[ viewIcon summary.current.icon
, Text.subHeading
[ span [ Html.Styled.Attributes.id (panelId summary.current) ]
[ Html.text summary.current.title ]
]
, viewContent summary.current.content
]
)
, ( panelId summary.current ++ "-footer"
, viewActiveFooter summary
|> Html.map config.parentMsg
)
]
viewBackdrop : Html msg -> Html msg
viewBackdrop modal =
Nri.Ui.styled div
"modal-backdrop-container"
(Css.backgroundColor (Nri.Ui.Colors.Extra.withAlpha 0.9 Colors.navy)
:: [ Css.height (Css.vh 100)
, Css.left Css.zero
, Css.overflow Css.hidden
, Css.position Css.fixed
, Css.top Css.zero
, Css.width (Css.pct 100)
, Css.zIndex (Css.int 200)
, Css.displayFlex
, Css.alignItems Css.center
, Css.justifyContent Css.center
]
)
[]
[ -- This global <style> node sets overflow to hidden on the body element,
-- thereby preventing the page from scrolling behind the backdrop when the modal is
-- open (and this node is present on the page).
Css.Global.global [ Css.Global.body [ Css.overflow Css.hidden ] ]
, modal
]
{-| Configuration for a single modal view in the sequence of modal views.
-}
type alias Panel =
{ icon : Html Never
, title : String
, content : Html Never
, buttonLabel : String
}
panelContainer : Css.Vh -> List Css.Style -> List (Html msg) -> Html msg
panelContainer height animationStyles panel =
div
[ css
[ -- Layout
Css.minHeight (Css.px 400)
, Css.minHeight (Css.px 360)
, Css.maxHeight <| Css.calc (Css.vh 100) Css.minus (Css.px 100)
, Css.height height
, Css.width (Css.px 600)
, Css.margin3 (Css.px 35) (Css.px 21) Css.zero
-- Interior positioning
, Css.displayFlex
, Css.alignItems Css.center
, Css.flexDirection Css.column
, Css.flexWrap Css.noWrap
-- Styles
, Fonts.baseFont
, Css.batch animationStyles
]
]
panel
panelId : Panel -> String
panelId { title } =
"modal-header__" ++ String.replace " " "-" title
viewContent : Html Never -> Html msg
viewContent content =
Nri.Ui.styled div
"modal-content"
[ Css.overflowY Css.auto
, Css.padding2 (Css.px 30) (Css.px 45)
, Css.width (Css.pct 100)
, Css.marginBottom Css.auto
, Css.boxSizing Css.borderBox
]
[]
[ Html.map never content ]
viewIcon : Html Never -> Html msg
viewIcon svg =
div
[ css
[ Css.width (Css.px 100)
, Css.height (Css.px 100)
, Css.flexShrink Css.zero
, Css.displayFlex
, Css.alignItems Css.center
, Css.justifyContent Css.center
, Css.Global.children
[ Css.Global.svg
[ Css.maxHeight (Css.px 100)
, Css.width (Css.px 100)
]
]
]
]
[ svg ]
|> Html.map never
viewActiveFooter : Summary -> Html State
viewActiveFooter { previous, current, upcoming } =
let
nextPanel =
List.head upcoming
|> Maybe.map Tuple.first
|> Maybe.withDefault closed
dots =
List.map (uncurry Inactive) previous
++ Active
:: List.map (uncurry InactiveDisabled) upcoming
in
viewFlexibleFooter
{ buttonLabel = current.buttonLabel
, buttonMsg = nextPanel
, buttonState = Button.Enabled
}
dots
viewFlexibleFooter :
{ buttonLabel : String
, buttonMsg : msg
, buttonState : Button.ButtonState
}
-> List (Dot msg)
-> Html msg
viewFlexibleFooter { buttonLabel, buttonMsg, buttonState } dotList =
Nri.Ui.styled div
"modal-footer"
[ Css.flexShrink Css.zero
, Css.displayFlex
, Css.flexDirection Css.column
, Css.alignItems Css.center
, Css.margin4 (Css.px 20) Css.zero (Css.px 25) Css.zero
]
[]
[ Button.button
{ onClick = buttonMsg
, size = Button.Large
, style = Button.Primary
, width = Button.WidthExact 230
}
{ label = buttonLabel
, state = buttonState
, icon = Nothing
}
, dotList
|> List.map dot
|> div [ css [ Css.marginTop (Css.px 16) ] ]
]
uncurry : (a -> b -> c) -> ( a, b ) -> c
uncurry f ( a, b ) =
f a b
type Dot msg
= Active
| Inactive msg String
| InactiveDisabled msg String
dot : Dot msg -> Html.Html msg
dot type_ =
let
styles ( startColor, endColor ) cursor =
css
[ Css.height (Css.px 10)
, Css.width (Css.px 10)
, Css.borderRadius (Css.px 5)
, Css.margin2 Css.zero (Css.px 2)
, Css.display Css.inlineBlock
, Css.verticalAlign Css.middle
, Css.cursor cursor
-- Color
, Css.animationDuration (Css.ms 600)
, Css.property "animation-timing-function" "linear"
, Css.animationName
(Css.Animations.keyframes
[ ( 0, [ animateBackgroundColor startColor ] )
, ( 100, [ animateBackgroundColor endColor ] )
]
)
, Css.backgroundColor endColor
-- resets
, Css.borderWidth Css.zero
, Css.padding Css.zero
, Css.hover [ Css.outline Css.none ]
]
animateBackgroundColor color =
Nri.Ui.Colors.Extra.toCoreColor color
|> Color.toCssString
|> Css.Animations.property "background-color"
in
case type_ of
Active ->
Html.div
[ styles ( Colors.gray75, Colors.azure ) Css.auto
]
[]
Inactive goTo title ->
Html.button
[ styles ( Colors.gray75, Colors.gray75 ) Css.pointer
, onClick goTo
]
[ span Accessibility.Styled.Style.invisible
[ text ("Go to " ++ title) ]
]
InactiveDisabled goTo title ->
Html.button
[ styles ( Colors.gray75, Colors.gray75 ) Css.auto
, Html.Styled.Attributes.disabled True
]
[ span Accessibility.Styled.Style.invisible
[ text ("Go to " ++ title) ]
]

View File

@ -0,0 +1,155 @@
module Examples.Slide exposing (Msg, State, example, init, update)
{-|
@docs Msg, State, example, init, update
-}
import Accessibility.Styled as Html
import Css
import Html.Styled.Attributes exposing (css)
import Html.Styled.Keyed as Keyed
import List.Zipper as Zipper exposing (Zipper(..))
import ModuleExample exposing (Category(..), ModuleExample)
import Nri.Ui.Button.V8 as Button
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.Slide.V1 as Slide
{-| -}
type Msg
= TriggerAnimation Slide.AnimationDirection
{-| -}
type alias State =
{ direction : Slide.AnimationDirection
, panels : Zipper Panel
, previous : Maybe Panel
}
{-| -}
example : (Msg -> msg) -> State -> ModuleExample msg
example parentMessage state =
{ filename = "Nri.Ui.Slide.V1.elm"
, category = Behaviors
, content =
[ Keyed.node "div"
[ css
[ Slide.withSlidingContents
, Css.border3 (Css.px 3) Css.solid Colors.gray75
, Css.padding (Css.px 20)
, Css.width (Css.px 600)
]
]
(case state.previous of
Just previousPanel ->
[ viewPanel previousPanel (Slide.animateOut state.direction)
, viewPanel (Zipper.current state.panels) (Slide.animateIn state.direction)
]
Nothing ->
[ viewPanel (Zipper.current state.panels) (Css.batch [])
]
)
, Html.div
[ css
[ Css.displayFlex
, Css.justifyContent Css.spaceBetween
, Css.marginTop (Css.px 20)
, Css.width (Css.px 300)
]
]
[ triggerAnimation Slide.FromLTR "Left-to-right"
, triggerAnimation Slide.FromRTL "Right-to-left"
]
]
|> List.map (Html.map parentMessage)
}
{-| -}
init : State
init =
{ direction = Slide.FromRTL
, panels = Zipper [] One [ Two, Three ]
, previous = Nothing
}
{-| -}
update : Msg -> State -> ( State, Cmd Msg )
update msg state =
case msg of
TriggerAnimation direction ->
( { state
| direction = direction
, panels =
case direction of
Slide.FromRTL ->
Zipper.next state.panels
|> Maybe.withDefault (Zipper.first state.panels)
Slide.FromLTR ->
Zipper.previous state.panels
|> Maybe.withDefault (Zipper.last state.panels)
, previous = Just (Zipper.current state.panels)
}
, Cmd.none
)
-- INTERNAL
type Panel
= One
| Two
| Three
viewPanel : Panel -> Css.Style -> ( String, Html.Html msg )
viewPanel panel animation =
let
( color, text, key ) =
case panel of
One ->
( Colors.red, "Panel One", "panel-1" )
Two ->
( Colors.yellow, "Panel Two", "panel-2" )
Three ->
( Colors.green, "Panel Three", "panel-3" )
in
( key
, Html.div
[ css
[ Css.border3 (Css.px 2) Css.dashed color
, Css.color color
, Css.padding (Css.px 10)
, Css.width (Css.px 100)
, Css.textAlign Css.center
, animation
]
]
[ Html.text text
]
)
triggerAnimation : Slide.AnimationDirection -> String -> Html.Html Msg
triggerAnimation direction label =
Button.button
{ onClick = TriggerAnimation direction
, size = Button.Small
, style = Button.Secondary
, width = Button.WidthUnbounded
}
{ label = label
, state = Button.Enabled
, icon = Nothing
}

View File

@ -14,7 +14,7 @@ import Html.Styled.Attributes exposing (css)
import ModuleExample exposing (Category(..), ModuleExample)
import Nri.Ui.Button.V8 as Button
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.SlideModal.V1 as SlideModal
import Nri.Ui.SlideModal.V2 as SlideModal
import Svg exposing (..)
import Svg.Attributes exposing (..)
@ -32,7 +32,7 @@ type alias State =
{-| -}
example : (Msg -> msg) -> State -> ModuleExample msg
example parentMessage state =
{ filename = "Nri.Ui.SlideModal.V1.elm"
{ filename = "Nri.Ui.SlideModal.V2.elm"
, category = Modals
, content =
[ viewModal state.modal

View File

@ -15,6 +15,7 @@ import Examples.Modal
import Examples.Page
import Examples.SegmentedControl
import Examples.Select
import Examples.Slide
import Examples.SlideModal
import Examples.Table
import Examples.Tabs
@ -41,6 +42,7 @@ type alias ModuleStates =
, disclosureIndicatorExampleState : Examples.DisclosureIndicator.State
, modalExampleState : Examples.Modal.State
, slideModalExampleState : Examples.SlideModal.State
, slideExampleState : Examples.Slide.State
, tabsExampleState : Examples.Tabs.Tab
}
@ -59,6 +61,7 @@ init =
, disclosureIndicatorExampleState = Examples.DisclosureIndicator.init
, modalExampleState = Examples.Modal.init
, slideModalExampleState = Examples.SlideModal.init
, slideExampleState = Examples.Slide.init
, tabsExampleState = Examples.Tabs.First
}
@ -77,6 +80,7 @@ type Msg
| DisclosureIndicatorExampleMsg Examples.DisclosureIndicator.Msg
| ModalExampleMsg Examples.Modal.Msg
| SlideModalExampleMsg Examples.SlideModal.Msg
| SlideExampleMsg Examples.Slide.Msg
| TabsExampleMsg Examples.Tabs.Tab
| NoOp
@ -197,6 +201,15 @@ update outsideMsg moduleStates =
, Cmd.map SlideModalExampleMsg cmd
)
SlideExampleMsg msg ->
let
( slideExampleState, cmd ) =
Examples.Slide.update msg moduleStates.slideExampleState
in
( { moduleStates | slideExampleState = slideExampleState }
, Cmd.map SlideExampleMsg cmd
)
TabsExampleMsg tab ->
( { moduleStates | tabsExampleState = tab }
, Cmd.none
@ -246,6 +259,7 @@ nriThemedModules model =
, Examples.Colors.example
, Examples.Modal.example ModalExampleMsg model.modalExampleState
, Examples.SlideModal.example SlideModalExampleMsg model.slideModalExampleState
, Examples.Slide.example SlideExampleMsg model.slideExampleState
, Examples.Tabs.example TabsExampleMsg model.tabsExampleState
]

View File

@ -0,0 +1,157 @@
module Spec.Nri.Ui.SlideModal.V2 exposing (all)
import Css
import Expect exposing (Expectation)
import Html.Styled as Html
import Json.Encode
import Nri.Ui.SlideModal.V2 as SlideModal
import Test exposing (..)
import Test.Html.Event as Event
import Test.Html.Query as Query
import Test.Html.Selector exposing (..)
all : Test
all =
describe "Nri.Ui.SlideModal.V2"
[ test "shows first panel when open" <|
\() ->
SlideModal.open
|> SlideModal.view
{ panels = threePanels
, height = Css.vh 60
, parentMsg = identity
}
|> Html.toUnstyled
|> Query.fromHtml
|> Expect.all
[ Query.has [ text "Title1", text "Content1" ]
, Query.hasNot [ text "Title2", text "Content2" ]
, Query.hasNot [ text "Title3", text "Content3" ]
]
, test "shows no panel when closed" <|
\() ->
SlideModal.closed
|> SlideModal.view
{ panels = threePanels
, height = Css.vh 60
, parentMsg = identity
}
|> Html.toUnstyled
|> Query.fromHtml
|> Expect.all
[ Query.hasNot [ text "Title1", text "Content1" ]
, Query.hasNot [ text "Title2", text "Content2" ]
, Query.hasNot [ text "Title3", text "Content3" ]
]
, test "can click through" <|
\() ->
{ panels = threePanels
, height = Css.vh 60
, parentMsg = identity
}
|> initTest
|> click "Continue1"
|> click "Continue2"
|> click "Continue3"
|> assertAndFinish
[ Query.hasNot [ text "Title1", text "Content1" ]
, Query.hasNot [ text "Title2", text "Content2" ]
, Query.hasNot [ text "Title3", text "Content3" ]
]
, test "can navigate back using the dots" <|
\() ->
{ panels = threePanels
, height = Css.vh 60
, parentMsg = identity
}
|> initTest
|> click "Continue1"
|> click "Go to Title1"
|> assertAndFinish
[ Query.has [ text "Title1", text "Content1" ]
, Query.hasNot [ text "Title2", text "Content2" ]
, Query.hasNot [ text "Title3", text "Content3" ]
]
, test "cannot navigate forward using the dots" <|
\() ->
{ panels = threePanels
, height = Css.vh 60
, parentMsg = identity
}
|> initTest
|> click "Continue1"
|> assertAndFinish
[ Query.has [ tag "button", containing [ text "Go to Title1" ] ]
, Query.hasNot [ tag "button", containing [ text "Go to Title2" ] ]
, Query.has [ tag "button", disabled True, containing [ text "Go to Title3" ] ]
]
]
threePanels : List SlideModal.Panel
threePanels =
[ { icon = Html.text "Icon1"
, title = "Title1"
, content = Html.text "Content1"
, buttonLabel = "Continue1"
}
, { icon = Html.text "Icon2"
, title = "Title2"
, content = Html.text "Content 2"
, buttonLabel = "Continue2"
}
, { icon = Html.text "Icon3"
, title = "Title3"
, content = Html.text "Content 3"
, buttonLabel = "Continue3"
}
]
type alias TestContext =
{ view : SlideModal.State -> Query.Single SlideModal.State
, state : Result String SlideModal.State
}
initTest : SlideModal.Config SlideModal.State -> TestContext
initTest config =
{ view = SlideModal.view config >> Html.toUnstyled >> Query.fromHtml
, state = Ok SlideModal.open
}
click : String -> TestContext -> TestContext
click buttonText =
simulate
(Query.find [ tag "button", containing [ text buttonText ] ])
Event.click
simulate :
(Query.Single SlideModal.State -> Query.Single SlideModal.State)
-> ( String, Json.Encode.Value )
-> TestContext
-> TestContext
simulate findElement event testContext =
{ testContext
| state =
Result.andThen
(testContext.view
>> findElement
>> Event.simulate event
>> Event.toResult
)
testContext.state
}
assertAndFinish : List (Query.Single SlideModal.State -> Expectation) -> TestContext -> Expectation
assertAndFinish expectations { view, state } =
case Result.map view state of
Ok query ->
Expect.all expectations query
Err err ->
Expect.fail err