Merge pull request #644 from NoRedInk/fab/radio-button

Show an outline for radio buttons on selection
This commit is contained in:
Tessa 2020-11-30 09:43:56 -08:00 committed by GitHub
commit 59aa66d97a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 440 additions and 33 deletions

View File

@ -49,6 +49,7 @@
"Nri.Ui.Pennant.V2",
"Nri.Ui.PremiumCheckbox.V6",
"Nri.Ui.RadioButton.V1",
"Nri.Ui.RadioButton.V2",
"Nri.Ui.SegmentedControl.V11",
"Nri.Ui.SegmentedControl.V12",
"Nri.Ui.SegmentedControl.V13",

View File

@ -2,9 +2,9 @@ module Nri.Ui.RadioButton.V1 exposing (view, premium)
{-| Changes from monolith version:
- uses Nri.Ui.Data.PremiumLevel rather than monolith version
- uses Nri.Ui.Html.* rather than deprecated monolith extras
- removes Role.radio from the radio input's label
- uses Nri.Ui.Data.PremiumLevel rather than monolith version
- uses Nri.Ui.Html.\* rather than deprecated monolith extras
- removes Role.radio from the radio input's label
@docs view, premium
@ -65,7 +65,6 @@ view config =
{-| A radio button that should be used for premium content.
This radio button is locked when the premium level of the content
is greater than the premium level of the teacher.

View File

@ -0,0 +1,423 @@
module Nri.Ui.RadioButton.V2 exposing (view, premium)
{-| Changes from V1:
- adds an outline when a radio button is focused
- remove NoOp/event swallowing (it broke default radio button behavior)
@docs view, premium
-}
import Accessibility.Styled exposing (..)
import Accessibility.Styled.Aria as Aria
import Accessibility.Styled.Style as Style
import Accessibility.Styled.Widget as Widget
import Css exposing (..)
import Css.Global
import Html.Styled as Html
import Html.Styled.Attributes exposing (..)
import Html.Styled.Events exposing (onClick, stopPropagationOn)
import Json.Decode
import Nri.Ui.ClickableSvg.V2 as ClickableSvg
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.Data.PremiumLevel as PremiumLevel exposing (PremiumLevel)
import Nri.Ui.Fonts.V1 as Fonts
import Nri.Ui.Html.Attributes.V2 as Attributes
import Nri.Ui.Html.V3 exposing (viewIf)
import Nri.Ui.Pennant.V2 as Pennant
import Nri.Ui.Svg.V1 exposing (Svg, fromHtml)
import String exposing (toLower)
import String.Extra exposing (dasherize)
import Svg.Styled as Svg
import Svg.Styled.Attributes as SvgAttributes
{-| View a single radio button.
If used in a group, all radio buttons in the group should have the same name attribute.
-}
view :
{ label : String
, value : a
, name : String
, selectedValue : Maybe a
, onSelect : a -> msg
, valueToString : a -> String
}
-> Html msg
view config =
internalView
{ label = config.label
, value = config.value
, name = config.name
, selectedValue = config.selectedValue
, isLocked = False
, isDisabled = False
, onSelect = config.onSelect
, premiumMsg = Nothing
, valueToString = config.valueToString
, showPennant = False
}
{-| A radio button that should be used for premium content.
This radio button is locked when the premium level of the content
is greater than the premium level of the teacher.
- `onChange`: A message for when the user selected the radio button
- `onLockedClick`: A message for when the user clicks a radio button they don't have PremiumLevel for.
If you get this message, you should show an `Nri.Premium.Model.view`
-}
premium :
{ label : String
, value : a
, name : String
, selectedValue : Maybe a
, teacherPremiumLevel : PremiumLevel
, contentPremiumLevel : PremiumLevel
, onSelect : a -> msg
, premiumMsg : msg
, valueToString : a -> String
, showPennant : Bool
, isDisabled : Bool
}
-> Html msg
premium config =
let
isLocked =
not <|
PremiumLevel.allowedFor
config.contentPremiumLevel
config.teacherPremiumLevel
in
internalView
{ label = config.label
, value = config.value
, name = config.name
, selectedValue = config.selectedValue
, isLocked = isLocked
, isDisabled = config.isDisabled
, onSelect = config.onSelect
, valueToString = config.valueToString
, premiumMsg = Just config.premiumMsg
, showPennant =
case config.contentPremiumLevel of
PremiumLevel.Premium ->
config.showPennant
PremiumLevel.PremiumWithWriting ->
config.showPennant
PremiumLevel.Free ->
False
}
type alias InternalConfig a msg =
{ label : String
, value : a
, name : String
, selectedValue : Maybe a
, isLocked : Bool
, isDisabled : Bool
, onSelect : a -> msg
, premiumMsg : Maybe msg
, valueToString : a -> String
, showPennant : Bool
}
internalView : InternalConfig a msg -> Html msg
internalView config =
let
isChecked =
config.selectedValue == Just config.value
id_ =
config.name ++ "-" ++ dasherize (toLower (config.valueToString config.value))
in
Html.span
[ id (id_ ++ "-container")
, classList [ ( "Nri-RadioButton-PremiumClass", config.showPennant ) ]
, css
[ position relative
, pseudoClass "focus-within"
[ Css.Global.descendants
[ Css.Global.class "Nri-RadioButton-RadioButtonIcon"
[ borderColor (rgb 0 95 204)
]
]
]
]
]
[ radio config.name
(config.valueToString config.value)
isChecked
[ id id_
, Widget.disabled (config.isLocked || config.isDisabled)
, if not config.isDisabled then
onClick (config.onSelect config.value)
else
Attributes.none
, class "Nri-RadioButton-HiddenRadioInput"
, css
[ position absolute
, top (px 4)
, left (px 4)
, opacity zero
]
]
, Html.label
[ for id_
, classList
[ ( "Nri-RadioButton-RadioButton", True )
, ( "Nri-RadioButton-RadioButtonChecked", isChecked )
]
, css
[ padding4 (px 4) zero (px 4) (px 40)
, if config.isDisabled then
Css.batch
[ color Colors.gray45
, cursor notAllowed
]
else
cursor pointer
, fontSize (px 15)
, Fonts.baseFont
, Css.property "font-weight" "600"
, position relative
, outline none
, margin zero
, display inlineBlock
, color Colors.navy
]
]
[ radioInputIcon
{ isLocked = config.isLocked
, isDisabled = config.isDisabled
, isChecked = isChecked
}
, span
(if config.showPennant then
[ css
[ displayFlex
, alignItems center
, Css.height (px 20)
]
]
else
[ css [ verticalAlign middle ] ]
)
[ Html.text config.label
, viewIf
(\() ->
ClickableSvg.button "Premium"
Pennant.premiumFlag
[ Maybe.map ClickableSvg.onClick config.premiumMsg
|> Maybe.withDefault (ClickableSvg.custom [])
, ClickableSvg.exactWidth 26
, ClickableSvg.exactHeight 24
, ClickableSvg.css [ marginLeft (px 8) ]
]
)
config.showPennant
]
]
]
onEnterAndSpacePreventDefault : msg -> Attribute msg
onEnterAndSpacePreventDefault msg =
Nri.Ui.Html.V3.onKeyUp
{ stopPropagation = False, preventDefault = True }
(\code ->
if code == 13 || code == 32 then
Just msg
else
Nothing
)
radioInputIcon :
{ isChecked : Bool
, isLocked : Bool
, isDisabled : Bool
}
-> Html msg
radioInputIcon config =
let
image =
case ( config.isDisabled, config.isLocked, config.isChecked ) of
( _, True, _ ) ->
lockedSvg
( True, _, _ ) ->
unselectedSvg
( _, False, True ) ->
selectedSvg
( _, False, False ) ->
unselectedSvg
in
div
[ classList
[ ( "Nri-RadioButton-RadioButtonIcon", True )
, ( "Nri-RadioButton-RadioButtonDisabled", config.isDisabled )
]
, css
[ Css.batch <|
if config.isDisabled then
[ opacity (num 0.4) ]
else
[]
, position absolute
, left zero
, top zero
, Css.property "transition" ".3s all"
, border3 (px 2) solid transparent
, borderRadius (px 50)
, padding (px 2)
, displayFlex
, justifyContent center
, alignItems center
]
]
[ image
|> Nri.Ui.Svg.V1.withHeight (Css.px 26)
|> Nri.Ui.Svg.V1.withWidth (Css.px 26)
|> Nri.Ui.Svg.V1.toHtml
]
unselectedSvg : Svg
unselectedSvg =
Svg.svg [ SvgAttributes.viewBox "0 0 27 27" ]
[ Svg.defs []
[ Svg.rect [ SvgAttributes.id "unselected-path-1", SvgAttributes.x "0", SvgAttributes.y "0", SvgAttributes.width "27", SvgAttributes.height "27", SvgAttributes.rx "13.5" ] []
, Svg.filter [ SvgAttributes.id "unselected-filter-2", SvgAttributes.x "-3.7%", SvgAttributes.y "-3.7%", SvgAttributes.width "107.4%", SvgAttributes.height "107.4%", SvgAttributes.filterUnits "objectBoundingBox" ] [ Svg.feOffset [ SvgAttributes.dx "0", SvgAttributes.dy "2", SvgAttributes.in_ "SourceAlpha", SvgAttributes.result "shadowOffsetInner1" ] [], Svg.feComposite [ SvgAttributes.in_ "shadowOffsetInner1", SvgAttributes.in2 "SourceAlpha", SvgAttributes.operator "arithmetic", SvgAttributes.k2 "-1", SvgAttributes.k3 "1", SvgAttributes.result "shadowInnerInner1" ] [], Svg.feColorMatrix [ SvgAttributes.values "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0", SvgAttributes.in_ "shadowInnerInner1" ] [] ]
]
, Svg.g
[ SvgAttributes.stroke "none"
, SvgAttributes.strokeWidth "1"
, SvgAttributes.fill "none"
, SvgAttributes.fillRule "evenodd"
]
[ Svg.g []
[ Svg.g []
[ Svg.use
[ SvgAttributes.fill "#EBEBEB"
, SvgAttributes.fillRule "evenodd"
, SvgAttributes.xlinkHref "#unselected-path-1"
]
[]
, Svg.use
[ SvgAttributes.fill "black"
, SvgAttributes.fillOpacity "1"
, SvgAttributes.filter "url(#unselected-filter-2)"
, SvgAttributes.xlinkHref "#unselected-path-1"
]
[]
]
]
]
]
|> Nri.Ui.Svg.V1.fromHtml
selectedSvg : Svg
selectedSvg =
Svg.svg [ SvgAttributes.viewBox "0 0 27 27" ]
[ Svg.defs []
[ Svg.rect [ SvgAttributes.id "selected-path-1", SvgAttributes.x "0", SvgAttributes.y "0", SvgAttributes.width "27", SvgAttributes.height "27", SvgAttributes.rx "13.5" ] []
, Svg.filter
[ SvgAttributes.id "selected-filter-2", SvgAttributes.x "-3.7%", SvgAttributes.y "-3.7%", SvgAttributes.width "107.4%", SvgAttributes.height "107.4%", SvgAttributes.filterUnits "objectBoundingBox" ]
[ Svg.feOffset [ SvgAttributes.dx "0", SvgAttributes.dy "2", SvgAttributes.in_ "SourceAlpha", SvgAttributes.result "shadowOffsetInner1" ] [], Svg.feComposite [ SvgAttributes.in_ "shadowOffsetInner1", SvgAttributes.in2 "SourceAlpha", SvgAttributes.operator "arithmetic", SvgAttributes.k2 "-1", SvgAttributes.k3 "1", SvgAttributes.result "shadowInnerInner1" ] [], Svg.feColorMatrix [ SvgAttributes.values "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0", SvgAttributes.in_ "shadowInnerInner1" ] [] ]
]
, Svg.g
[ SvgAttributes.stroke "none"
, SvgAttributes.strokeWidth "1"
, SvgAttributes.fill "none"
, SvgAttributes.fillRule "evenodd"
]
[ Svg.g []
[ Svg.g []
[ Svg.use
[ SvgAttributes.fill "#D4F0FF"
, SvgAttributes.fillRule "evenodd"
, SvgAttributes.xlinkHref "#selected-path-1"
]
[]
, Svg.use
[ SvgAttributes.fill "black"
, SvgAttributes.fillOpacity "1"
, SvgAttributes.filter "url(#selected-filter-2)"
, SvgAttributes.xlinkHref "#selected-path-1"
]
[]
]
, Svg.circle
[ SvgAttributes.fill "#146AFF"
, SvgAttributes.cx "13.5"
, SvgAttributes.cy "13.5"
, SvgAttributes.r "6.3"
]
[]
]
]
]
|> Nri.Ui.Svg.V1.fromHtml
lockedSvg : Svg
lockedSvg =
Svg.svg [ SvgAttributes.viewBox "0 0 30 30" ]
[ Svg.defs []
[ Svg.rect [ SvgAttributes.id "locked-path-1", SvgAttributes.x "0", SvgAttributes.y "0", SvgAttributes.width "30", SvgAttributes.height "30", SvgAttributes.rx "15" ] []
, Svg.filter [ SvgAttributes.id "locked-filter-2", SvgAttributes.x "-3.3%", SvgAttributes.y "-3.3%", SvgAttributes.width "106.7%", SvgAttributes.height "106.7%", SvgAttributes.filterUnits "objectBoundingBox" ] [ Svg.feOffset [ SvgAttributes.dx "0", SvgAttributes.dy "2", SvgAttributes.in_ "SourceAlpha", SvgAttributes.result "shadowOffsetInner1" ] [], Svg.feComposite [ SvgAttributes.in_ "shadowOffsetInner1", SvgAttributes.in2 "SourceAlpha", SvgAttributes.operator "arithmetic", SvgAttributes.k2 "-1", SvgAttributes.k3 "1", SvgAttributes.result "shadowInnerInner1" ] [], Svg.feColorMatrix [ SvgAttributes.values "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0", SvgAttributes.in_ "shadowInnerInner1" ] [] ]
]
, Svg.g
[ SvgAttributes.stroke "none"
, SvgAttributes.strokeWidth "1"
, SvgAttributes.fill "none"
, SvgAttributes.fillRule "evenodd"
]
[ Svg.g []
[ Svg.use
[ SvgAttributes.fill "#EBEBEB"
, SvgAttributes.fillRule "evenodd"
, SvgAttributes.xlinkHref "#locked-path-1"
]
[]
, Svg.use
[ SvgAttributes.fill "black"
, SvgAttributes.fillOpacity "1"
, SvgAttributes.filter "url(#locked-filter-2)"
, SvgAttributes.xlinkHref "#locked-path-1"
]
[]
]
, Svg.g
[ SvgAttributes.transform "translate(8.000000, 5.000000)"
]
[ Svg.path
[ SvgAttributes.d "M11.7991616,9.36211885 L11.7991616,5.99470414 C11.7991616,3.24052783 9.64616757,1 7.00011784,1 C4.35359674,1 2.20083837,3.24052783 2.20083837,5.99470414 L2.20083837,9.36211885 L1.51499133,9.36211885 C0.678540765,9.36211885 -6.21724894e-14,10.0675883 -6.21724894e-14,10.9381415 L-6.21724894e-14,18.9239773 C-6.21724894e-14,19.7945305 0.678540765,20.5 1.51499133,20.5 L12.48548,20.5 C13.3219306,20.5 14,19.7945305 14,18.9239773 L14,10.9383868 C14,10.0678336 13.3219306,9.36211885 12.48548,9.36211885 L11.7991616,9.36211885 Z M7.46324136,15.4263108 L7.46324136,17.2991408 C7.46324136,17.5657769 7.25560176,17.7816368 7.00011784,17.7816368 C6.74416256,17.7816368 6.53652295,17.5657769 6.53652295,17.2991408 L6.53652295,15.4263108 C6.01259238,15.228848 5.63761553,14.7080859 5.63761553,14.0943569 C5.63761553,13.3116195 6.24757159,12.6763045 7.00011784,12.6763045 C7.75195704,12.6763045 8.36285584,13.3116195 8.36285584,14.0943569 C8.36285584,14.7088218 7.98717193,15.2295839 7.46324136,15.4263108 L7.46324136,15.4263108 Z M9.98178482,9.36211885 L4.01821518,9.36211885 L4.01821518,5.99470414 C4.01821518,4.2835237 5.35597044,2.89122723 7.00011784,2.89122723 C8.64402956,2.89122723 9.98178482,4.2835237 9.98178482,5.99470414 L9.98178482,9.36211885 L9.98178482,9.36211885 Z"
, SvgAttributes.fill "#E68800"
]
[]
, Svg.path
[ SvgAttributes.d "M11.7991616,8.14770554 L11.7991616,4.8666348 C11.7991616,2.18307839 9.64616757,-7.10542736e-15 7.00011784,-7.10542736e-15 C4.35359674,-7.10542736e-15 2.20083837,2.18307839 2.20083837,4.8666348 L2.20083837,8.14770554 L1.51499133,8.14770554 C0.678540765,8.14770554 -6.21724894e-14,8.83508604 -6.21724894e-14,9.6833174 L-6.21724894e-14,17.4643881 C-6.21724894e-14,18.3126195 0.678540765,19 1.51499133,19 L12.48548,19 C13.3219306,19 14,18.3126195 14,17.4643881 L14,9.68355641 C14,8.83532505 13.3219306,8.14770554 12.48548,8.14770554 L11.7991616,8.14770554 Z M7.46324136,14.0564054 L7.46324136,15.8812141 C7.46324136,16.1410134 7.25560176,16.3513384 7.00011784,16.3513384 C6.74416256,16.3513384 6.53652295,16.1410134 6.53652295,15.8812141 L6.53652295,14.0564054 C6.01259238,13.8640057 5.63761553,13.3565966 5.63761553,12.7586042 C5.63761553,11.9959369 6.24757159,11.376912 7.00011784,11.376912 C7.75195704,11.376912 8.36285584,11.9959369 8.36285584,12.7586042 C8.36285584,13.3573136 7.98717193,13.8647228 7.46324136,14.0564054 L7.46324136,14.0564054 Z M9.98178482,8.14770554 L4.01821518,8.14770554 L4.01821518,4.8666348 C4.01821518,3.19933078 5.35597044,1.84273423 7.00011784,1.84273423 C8.64402956,1.84273423 9.98178482,3.19933078 9.98178482,4.8666348 L9.98178482,8.14770554 L9.98178482,8.14770554 Z"
, SvgAttributes.fill "#FEC709"
]
[]
]
]
]
|> Nri.Ui.Svg.V1.fromHtml

View File

@ -23,7 +23,7 @@ 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.V10 as Modal
import Nri.Ui.RadioButton.V1 as RadioButton
import Nri.Ui.RadioButton.V2 as RadioButton
import Nri.Ui.Text.V5 as Text
@ -31,7 +31,7 @@ import Nri.Ui.Text.V5 as Text
example : Example State Msg
example =
{ name = "RadioButton"
, version = 1
, version = 2
, state = init
, update = update
, subscriptions = subscriptions
@ -39,8 +39,16 @@ example =
, categories = [ Layout ]
, atomicDesignType = Atom
, keyboardSupport =
-- TODO: fix keyboard support.
[]
[ { keys = [ Arrow Left ]
, result = "Move the focus & select the radio button to the left"
}
, { keys = [ Arrow Right ]
, result = "Move the focus & select the radio button to the right"
}
, { keys = [ Space ]
, result = "Select the current radio button"
}
]
}
@ -50,7 +58,6 @@ view model =
[ Heading.h3 [] [ Html.text "RadioButton" ]
, Heading.h4 [] [ Html.text "view" ]
, viewVanilla model
, viewInvisibleLabel model
, Heading.h4 [] [ Html.text "premium" ]
, viewPremium model
, Modal.info
@ -83,44 +90,23 @@ viewVanilla state =
div [ css [ Css.margin (Css.px 8) ] ]
[ RadioButton.view
{ label = "Cats"
, showLabel = True
, value = "Cats"
, name = "radio-button-examples"
, selectedValue = state.selectedValue
, onSelect = Select
, noOpMsg = NoOp
, valueToString = identity
}
, RadioButton.view
{ label = "Dogs"
, showLabel = True
, value = "Dogs"
, name = "radio-button-examples"
, selectedValue = state.selectedValue
, onSelect = Select
, noOpMsg = NoOp
, valueToString = identity
}
]
viewInvisibleLabel : State -> Html Msg
viewInvisibleLabel state =
div [ css [ Css.margin (Css.px 8) ] ]
[ Heading.h4 [] [ Html.text "Invisible Label" ]
, RadioButton.view
{ label = "Shh"
, showLabel = False
, value = "I'm a secret... but not to screen readers"
, name = "Secret"
, selectedValue = state.selectedValue
, onSelect = Select
, noOpMsg = NoOp
, valueToString = \_ -> "i-m-a-secret-but-not-to-screen-readers"
}
]
viewPremium : State -> Html Msg
viewPremium state =
let
@ -149,7 +135,6 @@ viewPremium state =
-- and use the correct id, there's not much point in doing
-- so yet since the radio doesn't handle focus correctly.
, premiumMsg = ModalMsg (Modal.open "fake-id")
, noOpMsg = NoOp
, valueToString = identity
, showPennant = premiumConfig.showPennant
, isDisabled = False
@ -170,7 +155,6 @@ viewPremium state =
-- and use the correct id, there's not much point in doing
-- so yet since the radio doesn't handle focus correctly.
, premiumMsg = ModalMsg (Modal.open "fake-id")
, noOpMsg = NoOp
, valueToString = identity
, showPennant = premiumConfig.showPennant
, isDisabled = False
@ -191,7 +175,6 @@ viewPremium state =
-- and use the correct id, there's not much point in doing
-- so yet since the radio doesn't handle focus correctly.
, premiumMsg = ModalMsg (Modal.open "fake-id")
, noOpMsg = NoOp
, valueToString = identity
, showPennant = premiumConfig.showPennant
, isDisabled = True

View File

@ -45,6 +45,7 @@
"Nri.Ui.Pennant.V2",
"Nri.Ui.PremiumCheckbox.V6",
"Nri.Ui.RadioButton.V1",
"Nri.Ui.RadioButton.V2",
"Nri.Ui.SegmentedControl.V11",
"Nri.Ui.SegmentedControl.V12",
"Nri.Ui.SegmentedControl.V13",