cp src/Nri/Ui/Tooltip/V2.elm src/Nri/Ui/Tooltip/V3.elm

This commit is contained in:
Tessa Kelly 2022-04-25 14:46:03 -07:00
parent 6865ce825f
commit 296fce96cb

981
src/Nri/Ui/Tooltip/V3.elm Normal file
View File

@ -0,0 +1,981 @@
module Nri.Ui.Tooltip.V2 exposing
( view, toggleTip
, Attribute
, plaintext, html
, withoutTail
, onTop, onBottom, onLeft, onRight
, alignStart, alignMiddle, alignEnd
, exactWidth, fitToContent
, smallPadding, normalPadding, customPadding
, onClick, onHover
, open
, css, containerCss
, custom, customTriggerAttributes
, nriDescription, testId
, primaryLabel, auxillaryDescription
)
{-| Known issues:
- tooltips with focusable content (e.g., a link) will not handle focus correctly for
keyboard-only users when using the onHover attribute
Post-release patches:
- fix overlay for onClick toolTip having a border
- mark customTriggerAttributes as deprecated
- add containerCss
- adds `nriDescription` and `testId`
- fix <https://github.com/NoRedInk/noredink-ui/issues/766>
- use `Shadows`
Changes from V1:
- {Position, withPosition} -> {onTop, onBottom, onLeft, onRight}
- withTooltipStyleOverrides -> css
- {Width, withWidth} -> {exactWidth, fitToContent}
- {Padding, withPadding} -> {smallPadding, normalPadding}
- adds customPadding
- adds custom for custom attributes
- adds plaintext, html helpers for setting the content
- pass a list of attributes rather than requiring a pipeline to set up the tooltip
- move Trigger into the attributes
- change primaryLabel and auxillaryDescription to attributes, adding view
- move the onTrigger event to the attributes
- extraButtonAttrs becomes attribute `customTriggerAttributes`
- isOpen field becomes the `open` attribute
- fold toggleTip and view into each other, so there's less to maintain
- adds withoutTail
These tooltips follow the accessibility recommendations from: <https://inclusive-components.design/tooltips-toggletips>
Example usage:
Tooltip.view
{ trigger =
\attrs ->
ClickableText.button "Click me to open the tooltip"
[ ClickableText.custom attrs ]
, id = "my-tooltip"
}
[ Tooltip.plaintext "Gradebook"
, Tooltip.primaryLabel
, Tooltip.onClick MyOnTriggerMsg
, Tooltip.open True
]
@docs view, toggleTip
@docs Attribute
@docs plaintext, html
@docs withoutTail
@docs onTop, onBottom, onLeft, onRight
@docs alignStart, alignMiddle, alignEnd
@docs exactWidth, fitToContent
@docs smallPadding, normalPadding, customPadding
@docs onClick, onHover
@docs open
@docs css, containerCss
@docs custom, customTriggerAttributes
@docs nriDescription, testId
@docs primaryLabel, auxillaryDescription
-}
import Accessibility.Styled as Html exposing (Attribute, Html, text)
import Accessibility.Styled.Aria as Aria
import Accessibility.Styled.Key as Key
import Accessibility.Styled.Role as Role
import Css exposing (Color, Px, Style)
import Css.Global as Global
import EventExtras
import Html.Styled as Root
import Html.Styled.Attributes as Attributes
import Html.Styled.Events as Events
import Json.Encode as Encode
import Nri.Ui
import Nri.Ui.ClickableSvg.V2 as ClickableSvg
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.Fonts.V1 as Fonts
import Nri.Ui.Html.Attributes.V2 as ExtraAttributes
import Nri.Ui.Shadows.V1 as Shadows
import Nri.Ui.UiIcon.V1 as UiIcon
import String.Extra
{-| -}
type Attribute msg
= Attribute (Tooltip msg -> Tooltip msg)
type alias Tooltip msg =
{ direction : Direction
, alignment : Alignment
, tail : Tail
, content : List (Html msg)
, attributes : List (Html.Attribute Never)
, containerStyles : List Style
, tooltipStyleOverrides : List Style
, width : Width
, padding : Padding
, trigger : Maybe (Trigger msg)
, triggerAttributes : List (Html.Attribute msg)
, purpose : Purpose
, isOpen : Bool
}
buildAttributes : List (Attribute msg) -> Tooltip msg
buildAttributes =
let
defaultTooltip : Tooltip msg
defaultTooltip =
{ direction = OnTop
, alignment = Middle
, tail = WithTail
, content = []
, attributes = []
, containerStyles =
[ Css.boxSizing Css.borderBox
, Css.display Css.inlineBlock
, Css.textAlign Css.left
, Css.position Css.relative
]
, tooltipStyleOverrides = []
, width = Exactly 320
, padding = NormalPadding
, trigger = Nothing
, triggerAttributes = []
, purpose = PrimaryLabel
, isOpen = False
}
in
List.foldl (\(Attribute applyAttr) acc -> applyAttr acc) defaultTooltip
{-| -}
plaintext : String -> Attribute msg
plaintext content =
Attribute (\config -> { config | content = [ text content ] })
{-| -}
html : List (Html msg) -> Attribute msg
html content =
Attribute (\config -> { config | content = content })
type Tail
= WithTail
| WithoutTail
{-| Where should the tail be positioned relative to the tooltip?
-}
type Alignment
= Start Px
| Middle
| End Px
{-| Makes it so that the tooltip does not have a tail!
-}
withoutTail : Attribute msg
withoutTail =
Attribute (\config -> { config | tail = WithoutTail })
withAligment : Alignment -> Attribute msg
withAligment alignment =
Attribute (\config -> { config | alignment = alignment })
{-| Put the tail at the "start" of the tooltip.
For onTop & onBottom tooltips, this means "left".
For onLeft & onRight tooltip, this means "top".
__________
|_ ______|
\/
-}
alignStart : Px -> Attribute msg
alignStart position =
withAligment (Start position)
{-| Put the tail at the "middle" of the tooltip. This is the default behavior.
__________
|___ ____|
\/
-}
alignMiddle : Attribute msg
alignMiddle =
withAligment Middle
{-| Put the tail at the "end" of the tooltip.
For onTop & onBottom tooltips, this means "right".
For onLeft & onRight tooltip, this means "bottom".
__________
|______ _|
\/
-}
alignEnd : Px -> Attribute msg
alignEnd position =
withAligment (End position)
{-| Where should this tooltip be positioned relative to the trigger?
-}
type Direction
= OnTop
| OnBottom
| OnLeft
| OnRight
withPosition : Direction -> Attribute msg
withPosition direction =
Attribute (\config -> { config | direction = direction })
{-|
__________
| |
|___ ____|
\/
-}
onTop : Attribute msg
onTop =
withPosition OnTop
{-|
__________
| |
< |
|_________|
-}
onRight : Attribute msg
onRight =
withPosition OnRight
{-|
___/\_____
| |
|_________|
-}
onBottom : Attribute msg
onBottom =
withPosition OnBottom
{-|
__________
| |
| >
|_________|
-}
onLeft : Attribute msg
onLeft =
withPosition OnLeft
{-| Set some custom styles on the tooltip. These will be treated as overrides,
so be careful!
-}
css : List Style -> Attribute msg
css tooltipStyleOverrides =
Attribute (\config -> { config | tooltipStyleOverrides = tooltipStyleOverrides })
{-| Use this helper to add custom attributes.
Do NOT use this helper to add css styles, as they may not be applied the way
you want/expect if underlying styles change.
Instead, please use the `css` helper.
-}
custom : List (Html.Attribute Never) -> Attribute msg
custom attributes =
Attribute (\config -> { config | attributes = config.attributes ++ attributes })
{-| -}
nriDescription : String -> Attribute msg
nriDescription description =
custom [ ExtraAttributes.nriDescription description ]
{-| -}
testId : String -> Attribute msg
testId id_ =
custom [ ExtraAttributes.testId id_ ]
{-| DEPRECATED -- a future release will remove this helper.
-}
customTriggerAttributes : List (Html.Attribute msg) -> Attribute msg
customTriggerAttributes attributes =
Attribute (\config -> { config | triggerAttributes = config.triggerAttributes ++ attributes })
{-| -}
containerCss : List Style -> Attribute msg
containerCss styles =
Attribute (\config -> { config | containerStyles = config.containerStyles ++ styles })
{-| Should the tooltip be exactly some measurement or fit to the width of the
content?
-}
type Width
= Exactly Int
| FitToContent
withWidth : Width -> Attribute msg
withWidth width =
Attribute (\config -> { config | width = width })
{-| Define a size in `px` for the tooltips's total width. The default is 320px.
-}
exactWidth : Int -> Attribute msg
exactWidth width =
withWidth (Exactly width)
{-| Tooltip width fits its content.
-}
fitToContent : Attribute msg
fitToContent =
withWidth FitToContent
{-| How much padding should be around the content inside the tooltip?
-}
type Padding
= SmallPadding
| NormalPadding
| CustomPadding Float
paddingToStyle : Padding -> Style
paddingToStyle padding =
case padding of
SmallPadding ->
Css.padding2 (Css.px 10) (Css.px 13)
NormalPadding ->
Css.padding (Css.px 20)
CustomPadding padding_ ->
Css.padding (Css.px padding_)
withPadding : Padding -> Attribute msg
withPadding padding =
Attribute (\config -> { config | padding = padding })
{-| -}
smallPadding : Attribute msg
smallPadding =
withPadding SmallPadding
{-| This the default spacing.
-}
normalPadding : Attribute msg
normalPadding =
withPadding NormalPadding
{-| Pass in the desired spacing around the edge of the tooltip (pixels).
-}
customPadding : Float -> Attribute msg
customPadding value =
withPadding (CustomPadding value)
type Trigger msg
= OnHover (Bool -> msg)
| OnClick (Bool -> msg)
{-| The tooltip opens when hovering over the trigger element, and closes when the hover stops.
-}
onHover : (Bool -> msg) -> Attribute msg
onHover msg =
Attribute (\config -> { config | trigger = Just (OnHover msg) })
{-| The tooltip opens when clicking the root element, and closes when anything but the tooltip is clicked again.
-}
onClick : (Bool -> msg) -> Attribute msg
onClick msg =
Attribute (\config -> { config | trigger = Just (OnClick msg) })
type Purpose
= PrimaryLabel
| AuxillaryDescription
{-| Used when the content of the tooltip is the "primary label" for its content, for example,
when the trigger content is an icon. The tooltip content will supercede the content of the trigger
HTML for screen readers.
This is the default.
-}
primaryLabel : Attribute msg
primaryLabel =
Attribute (\config -> { config | purpose = PrimaryLabel })
{-| Used when the content of the tooltip provides an "auxillary description" for its content.
-}
auxillaryDescription : Attribute msg
auxillaryDescription =
Attribute (\config -> { config | purpose = AuxillaryDescription })
{-| -}
open : Bool -> Attribute msg
open isOpen =
Attribute (\config -> { config | isOpen = isOpen })
{-| Here's what the fields in the configuration record do:
- `trigger`: What element do you interact with to open the tooltip?
- `id`: A unique identifier used to associate the trigger with its content
-}
view :
{ trigger : List (Html.Attribute msg) -> Html msg
, id : String -- Accessibility: Used to match tooltip to trigger
}
-> List (Attribute msg)
-> Html msg
view config attributes =
viewTooltip_ config (buildAttributes attributes)
{-| Supplementary information triggered by a "?" icon.
-}
toggleTip : { label : String } -> List (Attribute msg) -> Html msg
toggleTip { label } attributes_ =
let
id =
String.Extra.dasherize label
in
view
{ trigger =
\events ->
ClickableSvg.button label
UiIcon.help
[ ClickableSvg.exactWidth 20
, ClickableSvg.exactHeight 20
, ClickableSvg.custom events
, ClickableSvg.css
[ -- Take up enough room within the document flow
Css.margin (Css.px 5)
]
]
, id = id
}
(custom
[ Attributes.class "Nri-Ui-Tooltip-V2-ToggleTip"
, Attributes.id id
]
:: attributes_
)
-- INTERNALS
viewTooltip_ :
{ trigger : List (Html.Attribute msg) -> Html msg
, id : String -- Accessibility: Used to match tooltip to trigger
}
-> Tooltip msg
-> Html msg
viewTooltip_ { trigger, id } tooltip =
let
( containerEvents, buttonEvents ) =
case tooltip.trigger of
Just (OnClick msg) ->
( []
, [ EventExtras.onClickStopPropagation
(msg (not tooltip.isOpen))
]
)
Just (OnHover msg) ->
( [ Events.onMouseEnter (msg True)
, Events.onMouseLeave (msg False)
]
, [ Events.onFocus (msg True)
-- TODO: this blur event means that we cannot focus links
-- that are within the tooltip without a mouse
, Events.onBlur (msg False)
, Events.onClick (msg True)
]
)
Nothing ->
( [], [] )
in
Nri.Ui.styled Root.div
"Nri-Ui-Tooltip-V2"
tooltip.containerStyles
containerEvents
[ Html.div
[ Attributes.css
[ -- using display flex not so that the wrapping div fits its
-- contents exactly. otherwise, it will be at least of the
-- size of a line of text, adding some extra vertical space
-- when the trigger is short (making it look like vertical
-- alignment is broken). if you ever need to change this back
-- to `block` or `inline-block`, consider adjusting the
-- `font-size` to zero to achieve a similar effect.
Css.displayFlex
]
]
[ trigger
((if tooltip.isOpen then
case tooltip.purpose of
PrimaryLabel ->
Aria.labeledBy id
AuxillaryDescription ->
Aria.describedBy [ id ]
else
-- when our tooltips are closed, they're not rendered in the
-- DOM. This means that the ID references above would be
-- invalid and jumping to a reference would not work, so we
-- skip labels and descriptions if the tooltip is closed.
Attributes.property "data-closed-tooltip" Encode.null
)
:: buttonEvents
++ tooltip.triggerAttributes
)
, hoverBridge tooltip
]
, viewOverlay tooltip
-- Popout is rendered after the overlay, to allow client code to give it
-- priority when clicking by setting its position
, viewTooltip id tooltip
]
{-| This is a "bridge" for the cursor to move from trigger content to tooltip, so the user can click on links, etc.
-}
hoverBridge : Tooltip msg -> Html msg
hoverBridge { isOpen, direction } =
let
bridgeLength =
tailSize + 5
in
if isOpen then
Nri.Ui.styled Html.div
"tooltip-hover-bridge"
[ Css.boxSizing Css.borderBox
, Css.padding (Css.px tailSize)
, Css.position Css.absolute
, Css.batch <|
case direction of
OnTop ->
[ Css.top (Css.px -bridgeLength)
, Css.left Css.zero
, Css.width (Css.pct 100)
, Css.height (Css.px tailSize)
]
OnRight ->
[ Css.right (Css.px -bridgeLength)
, Css.top Css.zero
, Css.width (Css.px tailSize)
, Css.height (Css.pct 100)
]
OnBottom ->
[ Css.bottom (Css.px -bridgeLength)
, Css.left Css.zero
, Css.width (Css.pct 100)
, Css.height (Css.px tailSize)
]
OnLeft ->
[ Css.left (Css.px -bridgeLength)
, Css.top Css.zero
, Css.width (Css.px tailSize)
, Css.height (Css.pct 100)
]
]
[]
[]
else
text ""
viewTooltip : String -> Tooltip msg -> Html msg
viewTooltip tooltipId config =
if config.isOpen then
viewOpenTooltip tooltipId config
else
text ""
viewOpenTooltip : String -> Tooltip msg -> Html msg
viewOpenTooltip tooltipId config =
Html.div
[ Attributes.css
[ Css.position Css.absolute
, positionTooltip config.direction config.alignment
, Css.boxSizing Css.borderBox
]
]
[ Html.div
([ Attributes.css
([ Css.boxSizing Css.borderBox
, Css.borderRadius (Css.px 8)
, case config.width of
Exactly width ->
Css.width (Css.px (toFloat width))
FitToContent ->
Css.whiteSpace Css.noWrap
, paddingToStyle config.padding
, Css.position Css.absolute
, Css.zIndex (Css.int 100)
]
++ config.tooltipStyleOverrides
)
, pointerBox config.tail config.direction config.alignment
-- We need to keep this animation in tests to make it pass: check out
-- the NoAnimations middleware. So if you change the name here, please
-- change that as well
, Attributes.class "dont-disable-animation"
, Role.toolTip
]
++ config.attributes
++ [ Attributes.id tooltipId ]
)
config.content
]
tailSize : Float
tailSize =
8
tooltipColor : Color
tooltipColor =
Colors.navy
offCenterOffset : Float
offCenterOffset =
20
{-| This returns an absolute positioning style attribute for the popout container for a given tail position.
-}
positionTooltip : Direction -> Alignment -> Style
positionTooltip direction alignment =
let
ltrPosition =
case alignment of
Start customOffset ->
Css.left customOffset
Middle ->
Css.left (Css.pct 50)
End customOffset ->
Css.right customOffset
topToBottomPosition =
case alignment of
Start customOffset ->
Css.top customOffset
Middle ->
Css.top (Css.pct 50)
End customOffset ->
Css.bottom customOffset
in
Css.batch <|
case direction of
OnTop ->
[ ltrPosition
, Css.top (Css.calc (Css.px (negate tailSize)) Css.minus (Css.px 2))
]
OnBottom ->
[ ltrPosition
, Css.bottom (Css.calc (Css.px (negate tailSize)) Css.minus (Css.px 2))
]
OnLeft ->
[ topToBottomPosition
, Css.left (Css.calc (Css.px (negate tailSize)) Css.minus (Css.px 2))
]
OnRight ->
[ topToBottomPosition
, Css.right (Css.calc (Css.px (negate tailSize)) Css.minus (Css.px 2))
]
pointerBox : Tail -> Direction -> Alignment -> Html.Attribute msg
pointerBox tail direction alignment =
Attributes.css
[ Css.backgroundColor Colors.navy
, Css.border3 (Css.px 1) Css.solid Colors.navy
, positioning direction alignment
, case tail of
WithTail ->
tailForDirection direction
WithoutTail ->
Css.batch []
, Fonts.baseFont
, Css.fontSize (Css.px 16)
, Css.fontWeight (Css.int 600)
, Css.color Colors.white
, Shadows.high
, Global.descendants [ Global.a [ Css.textDecoration Css.underline ] ]
, Global.descendants [ Global.a [ Css.color Colors.white ] ]
]
viewOverlay : Tooltip msg -> Html msg
viewOverlay { isOpen, trigger } =
case ( isOpen, trigger ) of
( True, Just (OnClick msg) ) ->
-- if we display the click-to-close overlay on hover, you will have to
-- close the overlay by moving the mouse out of the window or clicking.
viewCloseTooltipOverlay (msg False)
_ ->
text ""
viewCloseTooltipOverlay : msg -> Html msg
viewCloseTooltipOverlay msg =
Html.button
[ Attributes.css
[ Css.width (Css.pct 100)
, -- ancestor uses transform property, which interacts with
-- position: fixed, forcing this hack.
-- https://www.w3.org/TR/css-transforms-1/#propdef-transform
Css.height (Css.calc (Css.px 1000) Css.plus (Css.calc (Css.pct 100) Css.plus (Css.px 1000)))
, Css.left Css.zero
, Css.top (Css.px -1000)
, Css.cursor Css.pointer
, Css.position Css.fixed
, Css.zIndex (Css.int 90) -- TODO: From Nri.ZIndex in monolith, bring ZIndex here?
, Css.backgroundColor Css.transparent
, Css.border Css.zero
, Css.outline Css.none
]
, EventExtras.onClickStopPropagation msg
, Key.tabbable False
]
[]
-- TAILS
positioning : Direction -> Alignment -> Style
positioning direction alignment =
let
topBottomAlignment =
case alignment of
Start _ ->
Css.left (Css.px offCenterOffset)
Middle ->
Css.left (Css.pct 50)
End _ ->
Css.right (Css.px offCenterOffset)
rightLeftAlignment =
case alignment of
Start _ ->
Css.property "top" ("calc(-" ++ String.fromFloat tailSize ++ "px + " ++ String.fromFloat offCenterOffset ++ "px)")
Middle ->
Css.property "top" ("calc(-" ++ String.fromFloat tailSize ++ "px + 50%)")
End _ ->
Css.property "bottom" ("calc(-" ++ String.fromFloat tailSize ++ "px + " ++ String.fromFloat offCenterOffset ++ "px)")
in
case direction of
OnTop ->
Css.batch
[ Css.property "transform" "translate(-50%, -100%)"
, getTailPositioning
{ xAlignment = topBottomAlignment
, yAlignment = Css.top (Css.pct 100)
}
]
OnBottom ->
Css.batch
[ Css.property "transform" "translate(-50%, 0)"
, getTailPositioning
{ xAlignment = topBottomAlignment
, yAlignment = Css.bottom (Css.pct 100)
}
]
OnRight ->
Css.batch
[ Css.property "transform" "translate(0, -50%)"
, getTailPositioning
{ xAlignment = Css.right (Css.pct 100)
, yAlignment = rightLeftAlignment
}
]
OnLeft ->
Css.batch
[ Css.property "transform" "translate(-100%, -50%)"
, getTailPositioning
{ xAlignment = Css.left (Css.pct 100)
, yAlignment = rightLeftAlignment
}
]
tailForDirection : Direction -> Style
tailForDirection direction =
case direction of
OnTop ->
bottomTail
OnBottom ->
topTail
OnRight ->
leftTail
OnLeft ->
rightTail
bottomTail : Style
bottomTail =
Css.batch
[ Css.before
[ Css.borderTopColor tooltipColor
, Css.property "border-width" (String.fromFloat (tailSize + 1) ++ "px")
, Css.marginLeft (Css.px (-tailSize - 1))
]
, Css.after
[ Css.borderTopColor tooltipColor
, Css.property "border-width" (String.fromFloat tailSize ++ "px")
, Css.marginLeft (Css.px -tailSize)
]
]
topTail : Style
topTail =
Css.batch
[ Css.before
[ Css.borderBottomColor tooltipColor
, Css.property "border-width" (String.fromFloat (tailSize + 1) ++ "px")
, Css.marginLeft (Css.px (-tailSize - 1))
]
, Css.after
[ Css.borderBottomColor tooltipColor
, Css.property "border-width" (String.fromFloat tailSize ++ "px")
, Css.marginLeft (Css.px -tailSize)
]
]
rightTail : Style
rightTail =
Css.batch
[ Css.before
[ Css.borderLeftColor tooltipColor
, Css.property "border-width" (String.fromFloat (tailSize + 1) ++ "px")
]
, Css.after
[ Css.borderLeftColor tooltipColor
, Css.property "border-width" (String.fromFloat tailSize ++ "px")
, Css.marginTop (Css.px 1)
, Css.marginRight (Css.px 2)
]
]
leftTail : Style
leftTail =
Css.batch
[ Css.before
[ Css.borderRightColor tooltipColor
, Css.property "border-width" (String.fromFloat (tailSize + 1) ++ "px")
]
, Css.after
[ Css.borderRightColor tooltipColor
, Css.property "border-width" (String.fromFloat tailSize ++ "px")
, Css.marginTop (Css.px 1)
, Css.marginLeft (Css.px 2)
]
]
getTailPositioning : { xAlignment : Style, yAlignment : Style } -> Style
getTailPositioning config =
Css.batch
[ Css.before (positionTail config)
, Css.after (positionTail config)
]
positionTail : { xAlignment : Style, yAlignment : Style } -> List Style
positionTail { xAlignment, yAlignment } =
[ xAlignment
, yAlignment
, Css.property "border" "solid transparent"
, Css.property "content" "\" \""
, Css.height Css.zero
, Css.width Css.zero
, Css.position Css.absolute
, Css.pointerEvents Css.none
]