Implement Modal.V10

Which allows an easier way to customize the modal styling
This commit is contained in:
Ju Liu 2020-08-12 15:15:23 +01:00
parent f00a76bbaf
commit be9c2e42c5
5 changed files with 697 additions and 6 deletions

View File

@ -38,6 +38,7 @@
"Nri.Ui.Message.V1",
"Nri.Ui.Modal.V3",
"Nri.Ui.Modal.V9",
"Nri.Ui.Modal.V10",
"Nri.Ui.Page.V3",
"Nri.Ui.Palette.V1",
"Nri.Ui.Pennant.V2",

653
src/Nri/Ui/Modal/V10.elm Normal file
View File

@ -0,0 +1,653 @@
module Nri.Ui.Modal.V10 exposing
( Model, init, initOpen, isOpen
, Msg, open, close, update, subscriptions
, Attribute, hideTitle, css
, FocusManager(..), info, warning
)
{-| Changes from V9:
- adds hideTitle and css helpers
@docs Model, init, initOpen, isOpen
@docs Msg, open, close, update, subscriptions
@docs Attribute, hideTitle, css
@docs FocusManager, info, warning
import Html.Styled exposing (text)
import Nri.Ui.Modal.V10 as Modal
type Msg
= ModalMsg Modal.Msg
...
viewModal : Modal.Model -> Html Msg
viewModal modalState =
Modal.info
{ title = "Modal Title"
, wrapMsg = ModalMsg
, focusManager =
Modal.OneFocusableElement
(\{ onlyFocusableElement, closeButton } ->
{ content =
[ closeButton onlyFocusableElement
, text "Modal Content"
]
, footer = []
}
)
}
[ Modal.hideTitle
, Modal.css [ padding (px 10) ]
]
modalState
-}
import Accessibility.Styled as Html exposing (..)
import Accessibility.Styled.Aria as Aria
import Accessibility.Styled.Key as Key
import Accessibility.Styled.Role as Role
import Accessibility.Styled.Widget as Widget
import Browser
import Browser.Dom as Dom
import Browser.Events
import Color.Transparent as Transparent
import Css exposing (..)
import Css.Transitions
import Html.Styled as Root
import Html.Styled.Attributes as Attrs exposing (id)
import Html.Styled.Events exposing (onClick)
import Nri.Ui.Colors.Extra
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.Fonts.V1 as Fonts
import Nri.Ui.SpriteSheet
import Nri.Ui.Svg.V1
import Task
{-| -}
type Model
= Opened String
| Closed
{-| -}
init : Model
init =
Closed
{-| Pass the id of the element that should receive focus when the modal is closed.
-}
initOpen : String -> Model
initOpen =
Opened
{-| -}
isOpen : Model -> Bool
isOpen model =
case model of
Opened _ ->
True
Closed ->
False
type By
= EscapeKey
| OverlayClick
| Other
{-| -}
type Msg
= OpenModal String
| CloseModal By
| Focus String
| Focused (Result Dom.Error ())
{-| Include the subscription if you want the modal to dismiss on `Esc`.
-}
subscriptions : Model -> Sub Msg
subscriptions model =
case model of
Opened _ ->
Browser.Events.onKeyDown (Key.escape (CloseModal EscapeKey))
Closed ->
Sub.none
{-| -}
update : { dismissOnEscAndOverlayClick : Bool } -> Msg -> Model -> ( Model, Cmd Msg )
update { dismissOnEscAndOverlayClick } msg model =
case msg of
OpenModal returnFocusTo ->
( Opened returnFocusTo
, Dom.focus autofocusId
|> Task.onError (\_ -> Dom.focus firstId)
|> Task.attempt Focused
)
CloseModal by ->
let
closeModal returnFocusTo =
( Closed, Task.attempt Focused (Dom.focus returnFocusTo) )
in
case ( model, by, dismissOnEscAndOverlayClick ) of
( Opened returnFocusTo, _, True ) ->
closeModal returnFocusTo
( Opened returnFocusTo, Other, False ) ->
closeModal returnFocusTo
_ ->
( model, Cmd.none )
Focus id ->
( model, Task.attempt Focused (Dom.focus id) )
Focused _ ->
-- TODO: consider adding error handling when we didn't successfully
-- fous an element
( model, Cmd.none )
type Autofocus
= Default
| Last
{-| Pass the id of the element that should receive focus when the modal closes.
-}
open : String -> Msg
open =
OpenModal
{-| -}
close : Msg
close =
CloseModal Other
-- ATTRIBUTES
{-| Modals should allow the user to tab forwards & backwards through the modal content.
The user should never find their Focus lost behind the modal backdrop!
Use the `FocusManager` to tag the focusable elements in your modal, so that we
know to which element to return focus when the user reaches the last focusable element.
-}
type FocusManager msg
= MultipleFocusableElements
({ firstFocusableElement : List (Html.Attribute msg)
, lastFocusableElement : List (Html.Attribute msg)
, autofocusElement : Html.Attribute msg
, closeButton : List (Html.Attribute msg) -> Html msg
}
->
{ content : List (Html msg)
, footer : List (Html msg)
}
)
| OneFocusableElement
({ onlyFocusableElement : List (Html.Attribute msg)
, closeButton : List (Html.Attribute msg) -> Html msg
}
->
{ content : List (Html msg)
, footer : List (Html msg)
}
)
{-| -}
type Attribute
= Attribute (Attributes -> Attributes)
type alias Attributes =
{ overlayColor : Color
, titleColor : Color
, visibleTitle : Bool
, titleStyles : List Style
, customStyles : List Style
}
defaultAttributes : Attributes
defaultAttributes =
{ overlayColor = Nri.Ui.Colors.Extra.withAlpha 0.9 Colors.navy
, titleColor = Colors.navy
, visibleTitle = True
, titleStyles = []
, customStyles = []
}
{-| -}
hideTitle : Attribute
hideTitle =
Attribute (\attrs -> { attrs | visibleTitle = False })
{-| -}
css : List Style -> Attribute
css styles =
Attribute
(\attrs ->
{ attrs
| customStyles =
List.append attrs.customStyles styles
}
)
titleColor : Color -> Attribute
titleColor color =
Attribute
(\attrs -> { attrs | titleColor = color })
overlayColor : Color -> Attribute
overlayColor color =
Attribute
(\attrs -> { attrs | overlayColor = color })
buildAttributes : List Attribute -> Attributes -> Attributes
buildAttributes attrs initial =
let
result =
attrs
|> List.foldl (\(Attribute fun) acc -> fun acc) initial
in
{ result | titleStyles = titleStyles result.titleColor result.visibleTitle }
modalStyles : List Style
modalStyles =
[ position relative
-- Border
, borderRadius (px 20)
, boxShadow5 zero (px 1) (px 10) zero (rgba 0 0 0 0.35)
-- Spacing
, margin2 (px 50) auto
-- Size
, minHeight (vh 40)
, width (px 600)
, backgroundColor Colors.white
-- the modal should grow up to the viewport minus a 50px margin
, maxHeight (calc (pct 100) minus (px 100))
]
titleStyles : Color -> Bool -> List Style
titleStyles color visibleTitle =
if visibleTitle then
[ Fonts.baseFont
, Css.fontWeight (Css.int 700)
, Css.paddingTop (Css.px 40)
, Css.paddingBottom (Css.px 20)
, Css.margin Css.zero
, Css.fontSize (Css.px 20)
, Css.textAlign Css.center
, Css.color color
]
else
[ -- https://snook.ca/archives/html_and_css/hiding-content-for-accessibility
Css.property "clip" "rect(1px, 1px, 1px, 1px)"
, Css.position Css.absolute
, Css.height (Css.px 1)
, Css.width (Css.px 1)
, Css.overflow Css.hidden
, Css.margin (Css.px -1)
, Css.padding Css.zero
, Css.border Css.zero
]
-- VIEW
{-| -}
info :
{ title : String
, wrapMsg : Msg -> msg
, focusManager : FocusManager msg
}
-> List Attribute
-> Model
-> Html msg
info config attrsList =
view config attrsList
{-| -}
warning :
{ title : String
, wrapMsg : Msg -> msg
, focusManager : FocusManager msg
}
-> List Attribute
-> Model
-> Html msg
warning config attrsList =
view config
(List.append
[ overlayColor (Nri.Ui.Colors.Extra.withAlpha 0.9 Colors.gray20)
, titleColor Colors.red
]
attrsList
)
view :
{ title : String
, wrapMsg : Msg -> msg
, focusManager : FocusManager msg
}
-> List Attribute
-> Model
-> Html msg
view config attrsList model =
let
attrs =
buildAttributes attrsList defaultAttributes
in
case model of
Opened _ ->
div
[ Attrs.css
[ position fixed
, top zero
, left zero
, width (pct 100)
, height (pct 100)
, displayFlex
, alignItems center
]
]
[ viewBackdrop config.wrapMsg attrs.overlayColor
, div [ Attrs.css (List.append modalStyles attrs.customStyles) ] [ viewModal config attrs ]
, Root.node "style" [] [ Root.text "body {overflow: hidden;} " ]
]
|> List.singleton
|> div [ Attrs.css [ Css.position Css.relative, Css.zIndex (Css.int 1) ] ]
Closed ->
text ""
viewBackdrop : (Msg -> msg) -> Color -> Html msg
viewBackdrop wrapMsg color =
Root.div
-- We use Root html here in order to allow clicking to exit out of
-- the overlay. This behavior is available to non-mouse users as
-- well via the ESC key, so imo it's fine to have this div
-- be clickable but not focusable.
[ Attrs.css
[ position absolute
, width (pct 100)
, height (pct 100)
, backgroundColor color
]
, onClick (wrapMsg (CloseModal OverlayClick))
]
[]
viewModal :
{ title : String
, wrapMsg : Msg -> msg
, focusManager : FocusManager msg
}
->
{ a
| visibleTitle : Bool
, titleStyles : List Style
}
-> Html msg
viewModal config styles =
section
[ Role.dialog
, Aria.labeledBy modalTitleId
]
[ h1 [ id modalTitleId, Attrs.css styles.titleStyles ] [ text config.title ]
, viewContent styles.visibleTitle <|
case config.focusManager of
OneFocusableElement toContentAndFooter ->
toContentAndFooter
{ onlyFocusableElement =
List.map (Attrs.map config.wrapMsg)
[ Key.onKeyDown
[ Key.tabBack (Focus autofocusId)
, Key.tab (Focus autofocusId)
]
, id autofocusId
]
, closeButton = closeButton config.wrapMsg
}
MultipleFocusableElements toContentAndFooter ->
toContentAndFooter
{ firstFocusableElement =
List.map (Attrs.map config.wrapMsg)
[ Key.onKeyDown [ Key.tabBack (Focus lastId) ]
, id firstId
]
, lastFocusableElement =
List.map (Attrs.map config.wrapMsg)
[ Key.onKeyDown [ Key.tab (Focus firstId) ]
, id lastId
]
, autofocusElement =
Attrs.map config.wrapMsg (id autofocusId)
, closeButton = closeButton config.wrapMsg
}
]
{-| -}
viewContent : Bool -> { content : List (Html msg), footer : List (Html msg) } -> Html msg
viewContent visibleTitle { content, footer } =
div []
[ viewInnerContent content visibleTitle (not (List.isEmpty footer))
, viewFooter footer
]
{-| -}
viewInnerContent : List (Html msg) -> Bool -> Bool -> Html msg
viewInnerContent children visibleTitle visibleFooter =
let
titleHeight =
if visibleTitle then
45
else
0
footerHeight =
if visibleFooter then
180
else
0
modalTitleStyles =
if visibleTitle then
[]
else
[ Css.borderTopLeftRadius (Css.px 20)
, Css.borderTopRightRadius (Css.px 20)
, Css.overflowY Css.hidden
]
modalFooterStyles =
if visibleFooter then
[]
else
[ Css.borderBottomLeftRadius (Css.px 20)
, Css.borderBottomRightRadius (Css.px 20)
, Css.overflowY Css.hidden
]
in
div
[ Attrs.css (modalTitleStyles ++ modalFooterStyles)
]
[ div
[ Attrs.css
[ Css.overflowY Css.auto
, Css.overflowX Css.hidden
, Css.minHeight (Css.px 150)
, Css.maxHeight
(Css.calc (Css.vh 100)
Css.minus
(Css.px (footerHeight + titleHeight + 145))
)
, Css.width (Css.pct 100)
, Css.boxSizing Css.borderBox
, Css.paddingLeft (Css.px 40)
, Css.paddingRight (Css.px 40)
, if visibleTitle then
Css.paddingTop Css.zero
else
Css.paddingTop (Css.px 40)
, if visibleFooter then
Css.paddingBottom Css.zero
else
Css.paddingBottom (Css.px 40)
, if visibleFooter then
shadow (Transparent.customOpacity 0.15) (Css.px 16)
else
shadow (Transparent.customOpacity 0.4) (Css.px 30)
]
]
children
]
shadow : Transparent.Opacity -> Css.Px -> Css.Style
shadow opacity bottomShadowHeight =
let
to =
Transparent.fromRGBA { red = 0, green = 0, blue = 0, alpha = opacity }
|> Transparent.toRGBAString
in
Css.batch
[ -- Shadows for indicating that the content is scrollable
[ "/* TOP shadow */"
, "top linear-gradient(to top, rgb(255, 255, 255), rgb(255, 255, 255)) local,"
, "top linear-gradient(to top, rgba(255, 255, 255, 0), rgba(0, 0, 0, 0.15)) scroll,"
, ""
, "/* BOTTOM shadow */"
, "bottom linear-gradient(to bottom, rgb(255, 255, 255), rgb(255, 255, 255)) local,"
, "bottom linear-gradient(to bottom, rgba(255, 255, 255, 0), " ++ to ++ ") scroll"
]
|> String.join "\n"
|> Css.property "background"
, Css.backgroundSize2 (Css.pct 100) bottomShadowHeight
, Css.backgroundRepeat Css.noRepeat
]
{-| -}
viewFooter : List (Html msg) -> Html msg
viewFooter children =
if List.isEmpty children then
Html.text ""
else
div
[ Attrs.css
[ Css.alignItems Css.center
, Css.displayFlex
, Css.flexDirection Css.column
, Css.flexGrow (Css.int 2)
, Css.flexWrap Css.noWrap
, Css.margin4 (Css.px 20) Css.zero Css.zero Css.zero
, Css.paddingBottom (Css.px 40)
, Css.width (Css.pct 100)
]
]
children
--BUTTONS
{-| -}
closeButton : (Msg -> msg) -> List (Html.Attribute msg) -> Html msg
closeButton wrapMsg focusableElementAttrs =
button
(Widget.label "Close modal"
:: Attrs.map wrapMsg (onClick close)
:: Attrs.css
[ -- in the upper-right corner of the modal
Css.position Css.absolute
, Css.top Css.zero
, Css.right Css.zero
-- make the hitspace extend all the way to the corner
, Css.width (Css.px 40)
, Css.height (Css.px 40)
, Css.padding4 (Css.px 20) (Css.px 20) Css.zero Css.zero
-- apply button styles
, Css.borderWidth Css.zero
, Css.backgroundColor Css.transparent
, Css.cursor Css.pointer
, Css.color Colors.azure
, Css.hover [ Css.color Colors.azureDark ]
, Css.Transitions.transition [ Css.Transitions.color 0.1 ]
]
:: focusableElementAttrs
)
[ Nri.Ui.Svg.V1.toHtml Nri.Ui.SpriteSheet.xSvg
]
-- IDS
modalTitleId : String
modalTitleId =
"modal__title"
firstId : String
firstId =
"modal__first-focusable-element"
lastId : String
lastId =
"modal__last-focusable-element"
autofocusId : String
autofocusId =
"modal__autofocus-element"

View File

@ -19,7 +19,7 @@ import Nri.Ui.Button.V10 as Button
import Nri.Ui.Checkbox.V5 as Checkbox
import Nri.Ui.ClickableText.V3 as ClickableText
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.Modal.V9 as Modal
import Nri.Ui.Modal.V10 as Modal
import Nri.Ui.Text.V4 as Text
@ -52,6 +52,7 @@ type alias Settings =
, showSecondary : Bool
, dismissOnEscAndOverlayClick : Bool
, longContent : Bool
, customStyling : Bool
}
@ -63,6 +64,7 @@ initModalSettings =
, showSecondary = True
, dismissOnEscAndOverlayClick = True
, longContent = True
, customStyling = False
}
@ -78,6 +80,28 @@ example =
, subscriptions = subscriptions
, view =
\state ->
let
titleAttrs =
if state.settings.visibleTitle then
[]
else
[ Modal.hideTitle ]
stylingAttrs =
if state.settings.customStyling then
[ Modal.css
[ Css.borderRadius Css.zero
, Css.width (Css.px 800)
]
]
else
[]
attrs =
titleAttrs ++ stylingAttrs
in
[ viewSettings state.settings
, Button.button "Launch Info Modal"
[ Button.onClick (OpenModal Info "launch-info-modal")
@ -97,18 +121,18 @@ example =
Modal.info
{ title = "Modal.info"
, wrapMsg = ModalMsg
, visibleTitle = state.settings.visibleTitle
, focusManager = makeFocusManager Button.primary state.settings
}
attrs
state.state
Warning ->
Modal.warning
{ title = "Modal.warning"
, wrapMsg = ModalMsg
, visibleTitle = state.settings.visibleTitle
, focusManager = makeFocusManager Button.danger state.settings
}
attrs
state.state
]
}
@ -318,6 +342,14 @@ viewSettings settings =
, disabled = False
, theme = Checkbox.Square
}
, Checkbox.viewWithLabel
{ identifier = "custom-styles"
, label = "Custom Styling"
, selected = Checkbox.selectedFromBool settings.customStyling
, setterMsg = SetCustomStyling
, disabled = False
, theme = Checkbox.Square
}
]
@ -332,6 +364,7 @@ type Msg
| SetShowSecondary Bool
| SetDismissOnEscAndOverlayClick Bool
| SetLongContent Bool
| SetCustomStyling Bool
{-| -}
@ -378,6 +411,9 @@ update msg state =
SetLongContent value ->
( { state | settings = { settings | longContent = value } }, Cmd.none )
SetCustomStyling value ->
( { state | settings = { settings | customStyling = value } }, Cmd.none )
{-| -}
subscriptions : State -> Sub Msg

View File

@ -22,7 +22,7 @@ import KeyboardSupport exposing (Direction(..), Key(..))
import Nri.Ui.Button.V10 as Button
import Nri.Ui.Data.PremiumLevel as PremiumLevel exposing (PremiumLevel)
import Nri.Ui.Heading.V2 as Heading
import Nri.Ui.Modal.V9 as Modal
import Nri.Ui.Modal.V10 as Modal
import Nri.Ui.RadioButton.V1 as RadioButton
import Nri.Ui.Text.V5 as Text
@ -53,8 +53,7 @@ view model =
, Heading.h4 [] [ Html.text "premium" ]
, viewPremium model
, Modal.info
{ visibleTitle = True
, title = "Go Premium!"
{ title = "Go Premium!"
, wrapMsg = ModalMsg
, focusManager =
Modal.MultipleFocusableElements
@ -73,6 +72,7 @@ view model =
}
)
}
[]
model.modal
]

View File

@ -34,6 +34,7 @@
"Nri.Ui.Message.V1",
"Nri.Ui.Modal.V3",
"Nri.Ui.Modal.V9",
"Nri.Ui.Modal.V10",
"Nri.Ui.Page.V3",
"Nri.Ui.Palette.V1",
"Nri.Ui.Pennant.V2",