diff --git a/deprecated-modules.csv b/deprecated-modules.csv index b81497f8..f2553508 100644 --- a/deprecated-modules.csv +++ b/deprecated-modules.csv @@ -7,6 +7,7 @@ Nri.Ui.RadioButton.V2,upgrade to V4 Nri.Ui.RadioButton.V3,upgrade to V4 Nri.Ui.SideNav.V1,upgrade to V3 Nri.Ui.SideNav.V2,upgrade to V3 +Nri.Ui.Switch.V1,upgrade to V2 Nri.Ui.Table.V4,upgrade to V5 Nri.Ui.Tabs.V6,upgrade to V7 Nri.Ui.Tooltip.V1,upgrade to V2 diff --git a/elm.json b/elm.json index 8c8a007e..b8ac22d9 100644 --- a/elm.json +++ b/elm.json @@ -61,6 +61,7 @@ "Nri.Ui.Sprite.V1", "Nri.Ui.Svg.V1", "Nri.Ui.Switch.V1", + "Nri.Ui.Switch.V2", "Nri.Ui.Table.V4", "Nri.Ui.Table.V5", "Nri.Ui.Tabs.V6", diff --git a/forbidden-imports.toml b/forbidden-imports.toml index d34da00f..14c840fc 100644 --- a/forbidden-imports.toml +++ b/forbidden-imports.toml @@ -114,6 +114,9 @@ hint = 'upgrade to V3' [forbidden."Nri.Ui.SideNav.V2"] hint = 'upgrade to V3' +[forbidden."Nri.Ui.Switch.V1"] +hint = 'upgrade to V2' + [forbidden."Nri.Ui.Table.V4"] hint = 'upgrade to V5' diff --git a/script/puppeteer-tests.js b/script/puppeteer-tests.js index 007fa21b..1daab9b4 100644 --- a/script/puppeteer-tests.js +++ b/script/puppeteer-tests.js @@ -73,7 +73,6 @@ describe('UI tests', function () { // Loading's color contrast check seems to change behavior depending on whether Percy snapshots are taken or not 'Loading': ['color-contrast'], 'RadioButton': ['duplicate-id'], - 'Switch': ['aria-allowed-attr'], } const specialProcessing = { diff --git a/src/Nri/Ui/Colors/Extra.elm b/src/Nri/Ui/Colors/Extra.elm index 8f9dbda1..ca296ee6 100644 --- a/src/Nri/Ui/Colors/Extra.elm +++ b/src/Nri/Ui/Colors/Extra.elm @@ -1,6 +1,7 @@ module Nri.Ui.Colors.Extra exposing ( toCssColor, fromCssColor , withAlpha + , toCssString ) {-| Helpers for working with colors. @@ -10,6 +11,7 @@ module Nri.Ui.Colors.Extra exposing @docs toCssColor, fromCssColor @docs withAlpha +@docs toCssString -} @@ -44,3 +46,9 @@ withAlpha 0.5 grassland -- "{ value = "rgba(86, 191, 116, 0.5)", color = Compati withAlpha : Float -> Css.Color -> Css.Color withAlpha alpha { red, green, blue } = Css.rgba red green blue alpha + + +{-| -} +toCssString : Css.Color -> String +toCssString = + SolidColor.toRGBString << fromCssColor diff --git a/src/Nri/Ui/SortableTable/V2.elm b/src/Nri/Ui/SortableTable/V2.elm index e6905371..477fb113 100644 --- a/src/Nri/Ui/SortableTable/V2.elm +++ b/src/Nri/Ui/SortableTable/V2.elm @@ -21,11 +21,10 @@ import Css exposing (..) import Html.Styled as Html exposing (Html) import Html.Styled.Attributes exposing (css) import Html.Styled.Events -import Nri.Ui.Colors.Extra +import Nri.Ui.Colors.Extra exposing (toCssString) import Nri.Ui.Colors.V1 import Nri.Ui.CssVendorPrefix.V1 as CssVendorPrefix import Nri.Ui.Table.V5 -import SolidColor import Svg.Styled as Svg import Svg.Styled.Attributes as SvgAttributes @@ -363,8 +362,3 @@ sortArrow direction active = [ Svg.polygon [ SvgAttributes.points "0 6 4 0 8 6 0 6" ] [] ] ] - - -toCssString : Css.Color -> String -toCssString = - SolidColor.toRGBString << Nri.Ui.Colors.Extra.fromCssColor diff --git a/src/Nri/Ui/Switch/V2.elm b/src/Nri/Ui/Switch/V2.elm new file mode 100644 index 00000000..0dceb02e --- /dev/null +++ b/src/Nri/Ui/Switch/V2.elm @@ -0,0 +1,381 @@ +module Nri.Ui.Switch.V2 exposing + ( view + , Attribute + , selected + , containerCss, labelCss, custom, nriDescription, testId + , onSwitch, disabled + ) + +{-| + + +# Changes from V1: + + - Fixes invalid ARIA use, [conformance requirements](https://www.w3.org/TR/html-aria/#docconformance) + - labels should only support strings (this is the only way they're actually used in practice) + - extends API to be more consistent with other form/control components + - Use Colors values instead of hardcoded hex strings + - Move the status (selected or not selected) to the list api + - REQUIRE label and id always + - Move custom attributes to the container + - change disabled to take a bool (which I think is the slighty more common pattern) + +@docs view + + +### Attributes + +@docs Attribute +@docs selected +@docs containerCss, labelCss, custom, nriDescription, testId +@docs onSwitch, disabled + +-} + +import Accessibility.Styled as Html exposing (Html) +import Accessibility.Styled.Widget as Widget +import Css exposing (Color, Style) +import Css.Global as Global +import Css.Media +import Html.Styled.Attributes as Attributes +import Html.Styled.Events as Events +import Nri.Ui.Colors.Extra exposing (toCssString) +import Nri.Ui.Colors.V1 as Colors +import Nri.Ui.Fonts.V1 as Fonts +import Nri.Ui.Html.Attributes.V2 as Extra +import Nri.Ui.Svg.V1 exposing (Svg) +import Svg.Styled as Svg +import Svg.Styled.Attributes as SvgAttributes + + +{-| -} +type Attribute msg + = Attribute (Config msg -> Config msg) + + +{-| What is the status of the Switch, selected or not? +-} +selected : Bool -> Attribute msg +selected isSelected = + Attribute <| \config -> { config | isSelected = isSelected } + + +{-| Specify what happens when the switch is toggled. +-} +onSwitch : (Bool -> msg) -> Attribute msg +onSwitch onSwitch_ = + Attribute <| \config -> { config | onSwitch = Just onSwitch_ } + + +{-| Explicitly specify that you want this switch to be disabled. If you don't +specify `onSwitch`, this is the default, but it's provided so you don't have +to resort to `filterMap` or similar to build a clean list of attributes. +-} +disabled : Bool -> Attribute msg +disabled isDisabled = + Attribute <| \config -> { config | isDisabled = isDisabled } + + +{-| Pass custom attributes through to be attached to the underlying input. + +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 `containerCss` or `labelCss`. + +-} +custom : List (Html.Attribute Never) -> Attribute msg +custom custom_ = + Attribute <| \config -> { config | custom = config.custom ++ custom_ } + + +{-| -} +nriDescription : String -> Attribute msg +nriDescription description = + custom [ Extra.nriDescription description ] + + +{-| -} +testId : String -> Attribute msg +testId id_ = + custom [ Extra.testId id_ ] + + +{-| Adds CSS to the Switch container. +-} +containerCss : List Css.Style -> Attribute msg +containerCss styles = + Attribute <| \config -> { config | containerCss = config.containerCss ++ styles } + + +{-| Adds CSS to the element containing the label text. + +Note that these styles don't apply to the literal HTML label element, since it contains the icon SVG as well. + +-} +labelCss : List Css.Style -> Attribute msg +labelCss styles = + Attribute <| \config -> { config | labelCss = config.labelCss ++ styles } + + +type alias Config msg = + { onSwitch : Maybe (Bool -> msg) + , containerCss : List Style + , labelCss : List Style + , isDisabled : Bool + , isSelected : Bool + , custom : List (Html.Attribute Never) + } + + +defaultConfig : Config msg +defaultConfig = + { onSwitch = Nothing + , containerCss = [] + , labelCss = [] + , isDisabled = False + , isSelected = False + , custom = [] + } + + +{-| Render a switch. The boolean here indicates whether the switch is on +or not. +-} +view : { label : String, id : String } -> List (Attribute msg) -> Html msg +view { label, id } attrs = + let + config = + List.foldl (\(Attribute update) -> update) defaultConfig attrs + + notOperable = + config.onSwitch == Nothing || config.isDisabled + in + Html.label + ([ Attributes.id (id ++ "-container") + , Attributes.css + [ Css.display Css.inlineFlex + , Css.alignItems Css.center + , Css.position Css.relative + , Css.pseudoClass "focus-within" + [ Global.descendants + [ Global.class "switch-slider" + [ stroke Colors.azure + , Css.property "stroke-width" "3px" + ] + ] + ] + , Css.cursor + (if notOperable then + Css.notAllowed + + else + Css.pointer + ) + , Css.batch config.containerCss + ] + , Attributes.for id + ] + ++ List.map (Attributes.map never) config.custom + ) + [ viewCheckbox + { id = id + , onCheck = config.onSwitch + , isDisabled = config.isDisabled + , selected = config.isSelected + } + , Nri.Ui.Svg.V1.toHtml + (viewSwitch + { id = id + , isSelected = config.isSelected + , isDisabled = notOperable + } + ) + , Html.span + [ Attributes.css + [ Css.fontWeight (Css.int 600) + , Css.color Colors.navy + , Css.paddingLeft (Css.px 5) + , Fonts.baseFont + , Css.batch config.labelCss + ] + ] + [ Html.text label ] + ] + + +viewCheckbox : + { id : String + , onCheck : Maybe (Bool -> msg) + , selected : Bool + , isDisabled : Bool + } + -> Html msg +viewCheckbox config = + Html.checkbox config.id + (Just config.selected) + [ Attributes.id config.id + , Attributes.css + [ Css.position Css.absolute + , Css.top (Css.px 10) + , Css.left (Css.px 10) + , Css.zIndex (Css.int 0) + , Css.opacity (Css.num 0) + ] + , case ( config.onCheck, config.isDisabled ) of + ( Just onCheck, False ) -> + Events.onCheck onCheck + + _ -> + Widget.disabled True + ] + + +viewSwitch : + { id : String + , isSelected : Bool + , isDisabled : Bool + } + -> Svg +viewSwitch config = + let + shadowFilterId = + config.id ++ "-shadow-filter" + + shadowBoxId = + config.id ++ "-shadow-box" + in + Svg.svg + [ SvgAttributes.width "43" + , SvgAttributes.height "32" + , SvgAttributes.viewBox "0 0 43 32" + , SvgAttributes.css + [ Css.zIndex (Css.int 1) + , if config.isDisabled then + Css.opacity (Css.num 0.4) + + else + Css.opacity (Css.num 1) + ] + ] + [ Svg.defs [] + [ Svg.filter + [ SvgAttributes.id shadowFilterId + , SvgAttributes.width "105%" + , SvgAttributes.height "106.7%" + , SvgAttributes.x "-2.5%" + , SvgAttributes.y "-3.3%" + , SvgAttributes.filterUnits "objectBoundingBox" + ] + [ Svg.feOffset + [ SvgAttributes.dy "2" + , SvgAttributes.in_ "SourceAlpha" + , SvgAttributes.result "shadowOffsetInner1" + ] + [] + , Svg.feComposite + [ SvgAttributes.in_ "shadowOffsetInner1" + , SvgAttributes.in2 "SourceAlpha" + , SvgAttributes.k2 "-1" + , SvgAttributes.k3 "1" + , SvgAttributes.operator "arithmetic" + , SvgAttributes.result "shadowInnerInner1" + ] + [] + , Svg.feColorMatrix + [ SvgAttributes.in_ "shadowInnerInner1" + , SvgAttributes.values "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0" + ] + [] + ] + , Svg.rect + [ SvgAttributes.id shadowBoxId + , SvgAttributes.width "40" + , SvgAttributes.height "30" + , SvgAttributes.x "0" + , SvgAttributes.y "0" + , SvgAttributes.rx "15" + ] + [] + ] + , Svg.g + [ SvgAttributes.fill "none" + , SvgAttributes.fillRule "even-odd" + , SvgAttributes.transform "translate(1, 1)" + ] + [ Svg.g [] + [ Svg.use + [ SvgAttributes.xlinkHref ("#" ++ shadowBoxId) + , SvgAttributes.css + [ if config.isSelected then + Css.fill Colors.glacier + + else + Css.fill Colors.gray92 + , transition "fill 0.2s" + ] + ] + [] + , Svg.use + [ SvgAttributes.xlinkHref ("#" ++ shadowBoxId) + , SvgAttributes.fill "#000" + , SvgAttributes.filter ("url(#" ++ shadowFilterId ++ ")") + ] + [] + ] + , Svg.g + [ SvgAttributes.css + [ if config.isSelected then + Css.transform (Css.translateX (Css.px 11)) + + else + Css.transform (Css.translateX (Css.px 0)) + , transition "transform 0.2s ease-in-out" + ] + ] + [ Svg.circle + [ SvgAttributes.cx "15" + , SvgAttributes.cy "15" + , SvgAttributes.r "14.5" + , SvgAttributes.fill "#FFF" + , SvgAttributes.css + [ if config.isSelected then + stroke Colors.azure + + else + stroke Colors.gray75 + , transition "stroke 0.1s" + ] + , SvgAttributes.class "switch-slider" + ] + [] + , Svg.path + [ SvgAttributes.strokeLinecap "round" + , SvgAttributes.strokeLinejoin "round" + , SvgAttributes.strokeWidth "3" + , SvgAttributes.d "M8 15.865L12.323 20 21.554 10" + , SvgAttributes.css + [ if config.isSelected then + stroke Colors.azure + + else + stroke Colors.white + , transition "stroke 0.2s" + ] + ] + [] + ] + ] + ] + |> Nri.Ui.Svg.V1.fromHtml + + +stroke : Color -> Style +stroke color = + Css.property "stroke" (toCssString color) + + +transition : String -> Css.Style +transition transitionRules = + Css.Media.withMediaQuery + [ "(prefers-reduced-motion: no-preference)" ] + [ Css.property "transition" transitionRules ] diff --git a/styleguide-app/Debug/Control/View.elm b/styleguide-app/Debug/Control/View.elm index b4b75f7b..66554a95 100644 --- a/styleguide-app/Debug/Control/View.elm +++ b/styleguide-app/Debug/Control/View.elm @@ -1,4 +1,4 @@ -module Debug.Control.View exposing (codeFromList, codeFromListWithIndentLevel, view) +module Debug.Control.View exposing (codeFromList, codeFromListWithHardcoded, codeFromListWithIndentLevel, view) import Css exposing (..) import Css.Media exposing (withMedia) @@ -97,6 +97,13 @@ viewSection name children = ) +codeFromListWithHardcoded : List String -> List ( String, a ) -> String +codeFromListWithHardcoded hardcodes elements = + List.map (\v -> ( v, () )) hardcodes + ++ List.map (Tuple.mapSecond (always ())) elements + |> codeFromList + + codeFromList : List ( String, a ) -> String codeFromList = codeFromListWithIndentLevel 1 diff --git a/styleguide-app/Examples/Switch.elm b/styleguide-app/Examples/Switch.elm index 9e870fb7..9fc3c05a 100644 --- a/styleguide-app/Examples/Switch.elm +++ b/styleguide-app/Examples/Switch.elm @@ -8,71 +8,137 @@ module Examples.Switch exposing (Msg, State, example) import Accessibility.Styled.Key as Key import Category +import CommonControls +import Debug.Control as Control exposing (Control) +import Debug.Control.Extra as ControlExtra +import Debug.Control.View as ControlView import Example exposing (Example) -import Html.Styled as Html -import Nri.Ui.Heading.V2 as Heading -import Nri.Ui.Switch.V1 as Switch +import KeyboardSupport exposing (Key(..)) +import Nri.Ui.Switch.V2 as Switch + + +moduleName : String +moduleName = + "Switch" + + +version : Int +version = + 2 + + +example : Example State Msg +example = + { name = moduleName + , version = version + , state = init + , update = update + , subscriptions = \_ -> Sub.none + , preview = + [ Switch.view { label = "Toggle Off", id = "preview-switch-a" } + [ Switch.selected False + , Switch.custom [ Key.tabbable False ] + ] + , Switch.view { label = "Toggle On", id = "preview-switch-b" } + [ Switch.selected True + , Switch.custom [ Key.tabbable False ] + ] + ] + , view = + \ellieLinkConfig state -> + let + currentValue = + Control.currentValue state.settings + in + [ ControlView.view + { ellieLinkConfig = ellieLinkConfig + , name = moduleName + , version = version + , update = UpdateSettings + , settings = state.settings + , mainType = "RootHtml.Html msg" + , extraImports = [] + , toExampleCode = + \{ label, attributes } -> + [ { sectionName = "Example" + , code = + moduleName + ++ ".view" + ++ " \"" + ++ label + ++ "\"\t" + ++ ControlView.codeFromListWithHardcoded + [ "Switch.selected " + ++ Debug.toString state.selected + ++ "\n-- , Switch.onSwitch Switch -- <- you'll need to wire in a Msg for the Switch to work" + ] + attributes + } + ] + } + , Switch.view { label = currentValue.label, id = "view-switch-example" } + (Switch.selected state.selected + :: Switch.onSwitch Switch + :: List.map Tuple.second currentValue.attributes + ) + ] + , categories = [ Category.Inputs ] + , keyboardSupport = + [ { keys = [ Space ] + , result = "Toggle the Switch state" + } + ] + } {-| -} type alias State = - Bool + { selected : Bool + , settings : Control Settings + } + + +init : State +init = + { selected = True + , settings = controlSettings + } + + +type alias Settings = + { label : String + , attributes : List ( String, Switch.Attribute Msg ) + } + + +controlSettings : Control Settings +controlSettings = + Control.record Settings + |> Control.field "label" (Control.string "Show pandas in results") + |> Control.field "attributes" initAttributes + + +initAttributes : Control (List ( String, Switch.Attribute msg )) +initAttributes = + ControlExtra.list + |> CommonControls.disabledListItem moduleName Switch.disabled {-| -} type Msg = Switch Bool + | UpdateSettings (Control Settings) -example : Example State Msg -example = - { name = "Switch" - , version = 1 - , state = True - , update = \(Switch new) _ -> ( new, Cmd.none ) - , subscriptions = \_ -> Sub.none - , preview = - [ Switch.view - [ Switch.label (Html.text "Toggle On") - , Switch.custom [ Key.tabbable False ] - ] - False - , Switch.view - [ Switch.label (Html.text "Toggle Off") - , Switch.custom [ Key.tabbable False ] - ] - True - ] - , view = - \ellieLinkConfig interactiveIsOn -> - [ Heading.h2 [ Heading.style Heading.Subhead ] [ Html.text "Interactive" ] - , Switch.view - [ Switch.onSwitch Switch - , Switch.id "switch-interactive" - , Switch.label - (if interactiveIsOn then - Html.text "On" +update : Msg -> State -> ( State, Cmd Msg ) +update msg state = + case msg of + Switch bool -> + ( { state | selected = bool } + , Cmd.none + ) - else - Html.text "Off" - ) - ] - interactiveIsOn - , Heading.h2 [ Heading.style Heading.Subhead ] [ Html.text "Disabled (On)" ] - , Switch.view - [ Switch.disabled - , Switch.id "switch-disabled-on" - , Switch.label (Html.text "Permanently on") - ] - True - , Heading.h2 [ Heading.style Heading.Subhead ] [ Html.text "Disabled (Off)" ] - , Switch.view - [ Switch.disabled - , Switch.id "switch-disabled-off" - , Switch.label (Html.text "Permanently off") - ] - False - ] - , categories = [ Category.Inputs ] - , keyboardSupport = [{- TODO -}] - } + UpdateSettings settings -> + ( { state | settings = settings } + , Cmd.none + ) diff --git a/styleguide/tests/SwitchExampleSpec.elm b/styleguide/tests/SwitchExampleSpec.elm index fb28f262..ea58696d 100644 --- a/styleguide/tests/SwitchExampleSpec.elm +++ b/styleguide/tests/SwitchExampleSpec.elm @@ -1,6 +1,5 @@ module SwitchExampleSpec exposing (suite) -import Accessibility.Aria as Aria import ProgramTest exposing (..) import Routes exposing (Route) import Test exposing (..) @@ -20,17 +19,11 @@ suite = \() -> app route |> ensureViewHas [ text "Nri.Ui.Switch" ] - -- switch starts with aria-checked=true and text "On" - |> ensureViewHas - [ attribute (Aria.checked (Just True)) - , text "On" - ] + -- switch starts with checked=true + |> ensureViewHas [ checked True ] -- user can click the first switch - |> check "switch-interactive" "On" False - -- the switch now has aria-checked=false and text "Off" - |> ensureViewHas - [ attribute (Aria.checked (Just False)) - , text "Off" - ] + |> check "view-switch-example" "Show pandas in results" False + -- the switch now has checked=false + |> ensureViewHas [ checked False ] |> done ] diff --git a/tests/elm-verify-examples.json b/tests/elm-verify-examples.json index d289e0b3..e4b8bea7 100644 --- a/tests/elm-verify-examples.json +++ b/tests/elm-verify-examples.json @@ -57,6 +57,7 @@ "Nri.Ui.Sprite.V1", "Nri.Ui.Svg.V1", "Nri.Ui.Switch.V1", + "Nri.Ui.Switch.V2", "Nri.Ui.Table.V4", "Nri.Ui.Table.V5", "Nri.Ui.Tabs.V6",