diff --git a/src/Nri/Ui/SegmentedControl/V14.elm b/src/Nri/Ui/SegmentedControl/V14.elm new file mode 100644 index 00000000..4cf89cfd --- /dev/null +++ b/src/Nri/Ui/SegmentedControl/V14.elm @@ -0,0 +1,310 @@ +module Nri.Ui.SegmentedControl.V14 exposing + ( Option, view + , Radio, viewRadioGroup + , Positioning(..), Width(..) + ) + +{-| Post-release patches: + + - Fixes + +Changes from V12: + + - Adds tooltip support + - combine onFocus and onSelect into focusAndSelect msg handler (for tooltips) + +@docs Option, view +@docs Radio, viewRadioGroup +@docs Positioning, Width + +-} + +import Accessibility.Styled exposing (..) +import Accessibility.Styled.Aria as Aria +import Accessibility.Styled.Role as Role +import Accessibility.Styled.Style as Style +import Accessibility.Styled.Widget as Widget +import Css exposing (..) +import EventExtras +import Html.Styled +import Html.Styled.Attributes as Attributes exposing (css, href) +import Html.Styled.Events as Events +import Json.Encode as Encode +import Nri.Ui +import Nri.Ui.Colors.Extra exposing (withAlpha) +import Nri.Ui.Colors.V1 as Colors +import Nri.Ui.Fonts.V1 as Fonts +import Nri.Ui.Html.Attributes.V2 as AttributesExtra +import Nri.Ui.Svg.V1 as Svg exposing (Svg) +import Nri.Ui.Tooltip.V2 as Tooltip +import Nri.Ui.Util exposing (dashify) +import TabsInternal.V2 as TabsInternal + + +{-| -} +type Positioning + = Left Width + | Center + + +{-| -} +type Width + = FitContent + | FillContainer + + +{-| -} +type alias Radio value msg = + { value : value + , idString : String + , label : Html msg + , attributes : List (Attribute msg) + , icon : Maybe Svg + } + + +{-| Creates a set of radio buttons styled to look like a segmented control. + + - `onSelect`: the message to produce when an option is selected (clicked) by the user + - `toString`: function to get the radio value as a string + - `options`: the list of options available + - `selected`: if present, the value of the currently-selected option + - `positioning`: how to position and size the segmented control + - `legend`: + - value read to screenreader users to explain the radio group's purpose + - after lowercasing & dashifying, this value is used to group the radio buttons together + +-} +viewRadioGroup : + { onSelect : a -> msg + , options : List (Radio a msg) + , selected : Maybe a + , positioning : Positioning + , legend : String + } + -> Html msg +viewRadioGroup config = + let + numOptions = + List.length config.options + + viewRadio index option = + let + isSelected = + Just option.value == config.selected + in + Html.Styled.label + [ css + -- ensure that the focus state is visible, even + -- though the radio button that technically has focus + -- is not + (Css.pseudoClass "focus-within" + [ Css.property "outline-style" "auto" ] + :: styles config.positioning numOptions index isSelected + ) + ] + [ radio name option.idString isSelected <| + (Events.onCheck (\_ -> config.onSelect option.value) + :: css [ Css.opacity Css.zero ] + :: Attributes.attribute "data-nri-checked" + (if isSelected then + "true" + + else + "false" + ) + :: Style.invisible + ) + , div [] [ viewIcon option.icon, option.label ] + ] + + name = + dashify (String.toLower config.legend) + + legendId = + "legend-" ++ name + in + div + [ Role.radioGroup + , Aria.labelledBy legendId + , css + [ displayFlex + , cursor pointer + , case config.positioning of + Left _ -> + justifyContent flexStart + + Center -> + justifyContent center + ] + ] + (p (Attributes.id legendId :: Style.invisible) [ text config.legend ] + :: List.indexedMap viewRadio config.options + ) + + +{-| Tooltip defaults: `[Tooltip.smallPadding, Tooltip.onBottom, Tooltip.fitToContent]` +-} +type alias Option value msg = + { value : value + , idString : String + , label : Html msg + , attributes : List (Attribute msg) + , tabTooltip : List (Tooltip.Attribute msg) + , icon : Maybe Svg + , content : Html msg + } + + +{-| + + - `focusAndSelect` : the message to produce when an option is selected by the user + - `options`: the list of options available + - `selected`: the value of the currently-selected option + - `positioning`: how to position and size the segmented control + - `toUrl`: a optional function that takes a `route` and returns the URL of that route. You should always use pass a `toUrl` function when the segmented control options correspond to routes in your SPA. + +-} +view : + { focusAndSelect : { select : a, focus : Maybe String } -> msg + , options : List (Option a msg) + , selected : a + , positioning : Positioning + , toUrl : Maybe (a -> String) + } + -> Html msg +view config = + let + toInternalTab : Option a msg -> TabsInternal.Tab a msg + toInternalTab option = + { id = option.value + , idString = option.idString + , tabAttributes = option.attributes + , tabTooltip = + case config.positioning of + Left FillContainer -> + Tooltip.containerCss [ Css.width (Css.pct 100) ] :: option.tabTooltip + + _ -> + option.tabTooltip + , tabView = [ viewIcon option.icon, option.label ] + , panelView = option.content + , spaHref = Maybe.map (\toUrl -> toUrl option.value) config.toUrl + } + + { tabList, tabPanels } = + TabsInternal.views + { focusAndSelect = config.focusAndSelect + , selected = config.selected + , tabs = List.map toInternalTab config.options + , tabListStyles = + [ displayFlex + , cursor pointer + , marginBottom (px 10) + , case config.positioning of + Left _ -> + justifyContent flexStart + + Center -> + justifyContent center + ] + , tabStyles = styles config.positioning (List.length config.options) + } + in + div [] + [ tabList + , tabPanels + ] + + +viewIcon : Maybe Svg.Svg -> Html msg +viewIcon icon = + case icon of + Nothing -> + text "" + + Just svg -> + svg + |> Svg.withWidth (px 18) + |> Svg.withHeight (px 18) + |> Svg.withCss + [ display inlineBlock + , verticalAlign textTop + , lineHeight (px 15) + , marginRight (px 8) + ] + |> Svg.toHtml + + +styles : Positioning -> Int -> Int -> Bool -> List Style +styles positioning numEntries index isSelected = + [ sharedSegmentStyles numEntries index + , if isSelected then + focusedSegmentStyles + + else + unFocusedSegmentStyles + , Css.batch <| + case positioning of + Left FillContainer -> + [ width (Css.pct 100) + , flexGrow (int 1) + , textAlign center + ] + + _ -> + [] + ] + + +sharedSegmentStyles : Int -> Int -> Style +sharedSegmentStyles numEntries index = + [ padding2 (px 6) (px 15) + , height (px 45) + , Fonts.baseFont + , fontSize (px 15) + , fontWeight bold + , lineHeight (px 30) + , margin zero + , border3 (px 1) solid Colors.azure + , boxSizing borderBox + , cursor pointer + , property "transition" "background-color 0.2s, color 0.2s, box-shadow 0.2s, border 0.2s, border-width 0s" + , textDecoration none + , hover [ textDecoration none ] + , focus [ textDecoration none ] + ] + ++ (if index == 0 then + [ borderTopLeftRadius (px 8) + , borderBottomLeftRadius (px 8) + ] + + else if index == numEntries - 1 then + [ borderTopRightRadius (px 8) + , borderBottomRightRadius (px 8) + , borderLeft (px 0) + ] + + else + [ borderLeft (px 0) ] + ) + |> Css.batch + + +focusedSegmentStyles : Style +focusedSegmentStyles = + [ backgroundColor Colors.glacier + , boxShadow5 inset zero (px 3) zero (withAlpha 0.2 Colors.gray20) + , color Colors.navy + ] + |> Css.batch + + +unFocusedSegmentStyles : Style +unFocusedSegmentStyles = + [ backgroundColor Colors.white + , boxShadow5 inset zero (px -2) zero Colors.azure + , color Colors.azure + , hover [ backgroundColor Colors.frost ] + ] + |> Css.batch diff --git a/styleguide-app/Examples/SegmentedControl.elm b/styleguide-app/Examples/SegmentedControl.elm index f19699ec..526145b7 100644 --- a/styleguide-app/Examples/SegmentedControl.elm +++ b/styleguide-app/Examples/SegmentedControl.elm @@ -21,7 +21,7 @@ import Html.Styled.Attributes as Attributes exposing (css) import Html.Styled.Events as Events import KeyboardSupport exposing (Direction(..), Key(..)) import Nri.Ui.Colors.V1 as Colors -import Nri.Ui.SegmentedControl.V13 as SegmentedControl +import Nri.Ui.SegmentedControl.V14 as SegmentedControl import Nri.Ui.Svg.V1 as Svg exposing (Svg) import Nri.Ui.Tooltip.V2 as Tooltip import Nri.Ui.UiIcon.V1 as UiIcon @@ -33,7 +33,7 @@ import Task example : Example State Msg example = { name = "SegmentedControl" - , version = 13 + , version = 14 , state = init , update = update , subscriptions = \_ -> Sub.none